From 8b2c6da35c47d18258721b45c1cb1ae0d0984db8 Mon Sep 17 00:00:00 2001 From: abigailllr Date: Sun, 14 Jun 2026 00:42:57 +0200 Subject: [PATCH] security fixes --- app/android/app/build.gradle.kts | 32 +- app/android/app/src/main/AndroidManifest.xml | 5 +- app/android/key.properties.example | 4 + app/ios/Runner/PrivacyInfo.xcprivacy | 56 + app/lib/config.dart | 18 + app/lib/main.dart | 30 +- app/lib/providers/auth_provider.dart | 40 +- app/lib/screens/login_screen.dart | 58 +- app/lib/screens/profile_screen.dart | 105 ++ app/lib/services/api_service.dart | 16 +- app/lib/services/token_store.dart | 25 + app/pubspec.yaml | 2 + backend/db/users.go | 23 +- backend/handlers/account.go | 6 +- backend/handlers/admin.go | 1 + .../2338ce6d-5e1c-4f51-bda6-1ceab6bf85b1.mp4 | Bin 512 -> 0 bytes .../caaf6cc9-fcc2-4b5c-9c7d-4bc0e7fd3d04.mp4 | Bin 512 -> 0 bytes backend/handlers/datasets.go | 17 +- backend/handlers/export.go | 32 +- backend/handlers/profile.go | 10 +- backend/handlers/referral.go | 16 +- backend/handlers/security_test.go | 87 ++ backend/handlers/stats.go | 12 +- backend/handlers/stream.go | 6 +- backend/handlers/submissions.go | 12 +- backend/handlers/submit.go | 10 +- backend/handlers/webhooks.go | 28 + backend/middleware/auth.go | 15 + backend/middleware/security.go | 4 +- backend/pipeline/pipeline.go | 31 +- backend/storage/s3_store.go | 10 +- web/.gitignore | 7 + web/next.config.ts | 7 +- web/package-lock.json | 1012 +++++++++++++++++ web/src/app/challenges/page.tsx | 17 - web/src/app/globals.css | 9 +- web/src/app/layout.tsx | 4 +- web/src/app/page.tsx | 54 +- web/src/app/stats/page.tsx | 42 - web/src/components/ChallengeForm.tsx | 78 -- web/src/components/ChallengeList.tsx | 119 -- web/src/components/Download.tsx | 42 + web/src/components/Features.tsx | 54 + web/src/components/Footer.tsx | 36 + web/src/components/Hero.tsx | 86 ++ web/src/components/Nav.tsx | 49 + web/src/components/Steps.tsx | 59 + web/src/lib/api.ts | 50 - 48 files changed, 2013 insertions(+), 423 deletions(-) create mode 100644 app/android/key.properties.example create mode 100644 app/ios/Runner/PrivacyInfo.xcprivacy create mode 100644 app/lib/config.dart create mode 100644 app/lib/services/token_store.dart delete mode 100644 backend/handlers/data/videos/test-user/2338ce6d-5e1c-4f51-bda6-1ceab6bf85b1.mp4 delete mode 100644 backend/handlers/data/videos/test-user/caaf6cc9-fcc2-4b5c-9c7d-4bc0e7fd3d04.mp4 create mode 100644 backend/handlers/security_test.go create mode 100644 web/.gitignore create mode 100644 web/package-lock.json delete mode 100644 web/src/app/challenges/page.tsx delete mode 100644 web/src/app/stats/page.tsx delete mode 100644 web/src/components/ChallengeForm.tsx delete mode 100644 web/src/components/ChallengeList.tsx create mode 100644 web/src/components/Download.tsx create mode 100644 web/src/components/Features.tsx create mode 100644 web/src/components/Footer.tsx create mode 100644 web/src/components/Hero.tsx create mode 100644 web/src/components/Nav.tsx create mode 100644 web/src/components/Steps.tsx delete mode 100644 web/src/lib/api.ts diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index f57a7e6..a282cb6 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -1,10 +1,18 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { id("com.android.application") id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.humanloop.humanloop" compileSdk = flutter.compileSdkVersion @@ -20,21 +28,31 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.humanloop.humanloop" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 473551b..56231c2 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -7,9 +7,10 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="false"> + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePreciseLocation + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherUserContent + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + diff --git a/app/lib/config.dart b/app/lib/config.dart new file mode 100644 index 0000000..d9eed11 --- /dev/null +++ b/app/lib/config.dart @@ -0,0 +1,18 @@ +class AppConfig { + static const apiBaseUrl = String.fromEnvironment( + 'API_URL', + defaultValue: 'https://api.humanloop.app', + ); + + static const privacyPolicyUrl = String.fromEnvironment( + 'PRIVACY_URL', + defaultValue: 'https://humanloop.app/privacy', + ); + + static const termsUrl = String.fromEnvironment( + 'TERMS_URL', + defaultValue: 'https://humanloop.app/terms', + ); + + static const supportEmail = 'support@humanloop.app'; +} diff --git a/app/lib/main.dart b/app/lib/main.dart index ddcf9a2..e729393 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -38,7 +38,9 @@ class HumanLoopApp extends ConsumerWidget { final hasOnboarded = prefs.getBool('onboarded') ?? false; Widget home; - if (auth.isSignedIn) { + if (auth.loading) { + home = const _SplashScreen(); + } else if (auth.isSignedIn) { home = const RootNav(); } else if (hasOnboarded) { home = const LoginScreen(); @@ -64,6 +66,32 @@ class HumanLoopApp extends ConsumerWidget { } } +class _SplashScreen extends StatelessWidget { + const _SplashScreen(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(24)), + child: const Icon(Icons.precision_manufacturing_rounded, color: Colors.white, size: 44), + ), + const SizedBox(height: 32), + const CircularProgressIndicator(color: AppColors.primary), + ], + ), + ), + ); + } +} + class RootNav extends StatefulWidget { const RootNav({super.key}); diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index bdf2747..10c4805 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -1,8 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../main.dart'; import '../models/user.dart'; import '../services/api_service.dart'; import '../services/auth_service.dart'; +import '../services/token_store.dart'; class AuthState { final AppUser? user; @@ -27,14 +27,18 @@ class AuthState { class AuthNotifier extends Notifier { @override AuthState build() { - final prefs = ref.read(prefsProvider); - final accessToken = prefs.getString('auth_token'); - final storedRefresh = prefs.getString('refresh_token'); - if (accessToken != null) { - Future.microtask(() => _restoreFromToken(accessToken, storedRefresh)); - return AuthState(token: accessToken, refreshToken: storedRefresh); + Future.microtask(_init); + return const AuthState(loading: true); + } + + Future _init() async { + final accessToken = await TokenStore.readAccess(); + final storedRefresh = await TokenStore.readRefresh(); + if (accessToken == null) { + state = const AuthState(); + return; } - return const AuthState(); + await _restoreFromToken(accessToken, storedRefresh); } Future _restoreFromToken(String token, String? refreshToken) async { @@ -50,17 +54,13 @@ class AuthNotifier extends Notifier { if (tokens != null) { final profile = await ApiService.getProfile(token: tokens.accessToken); if (profile.isNotEmpty) { - final prefs = ref.read(prefsProvider); - await prefs.setString('auth_token', tokens.accessToken); - await prefs.setString('refresh_token', tokens.refreshToken); + await TokenStore.save(tokens.accessToken, tokens.refreshToken); state = AuthState(user: AppUser.fromJson(profile), token: tokens.accessToken, refreshToken: tokens.refreshToken); return; } } } - final prefs = ref.read(prefsProvider); - await prefs.remove('auth_token'); - await prefs.remove('refresh_token'); + await TokenStore.clear(); state = const AuthState(); } @@ -68,9 +68,7 @@ class AuthNotifier extends Notifier { state = state.copyWith(loading: true); try { final result = await AuthService.signInWithGoogle(); - final prefs = ref.read(prefsProvider); - await prefs.setString('auth_token', result.tokens.accessToken); - await prefs.setString('refresh_token', result.tokens.refreshToken); + await TokenStore.save(result.tokens.accessToken, result.tokens.refreshToken); state = AuthState(user: result.user, token: result.tokens.accessToken, refreshToken: result.tokens.refreshToken); } catch (e) { state = state.copyWith(loading: false); @@ -82,9 +80,7 @@ class AuthNotifier extends Notifier { state = state.copyWith(loading: true); try { final result = await AuthService.signInWithApple(); - final prefs = ref.read(prefsProvider); - await prefs.setString('auth_token', result.tokens.accessToken); - await prefs.setString('refresh_token', result.tokens.refreshToken); + await TokenStore.save(result.tokens.accessToken, result.tokens.refreshToken); state = AuthState(user: result.user, token: result.tokens.accessToken, refreshToken: result.tokens.refreshToken); } catch (e) { state = state.copyWith(loading: false); @@ -104,9 +100,7 @@ class AuthNotifier extends Notifier { if (token != null && refresh != null) { await ApiService.revokeToken(token, refresh); } - final prefs = ref.read(prefsProvider); - await prefs.remove('auth_token'); - await prefs.remove('refresh_token'); + await TokenStore.clear(); state = const AuthState(); } } diff --git a/app/lib/screens/login_screen.dart b/app/lib/screens/login_screen.dart index 710a085..10833d1 100644 --- a/app/lib/screens/login_screen.dart +++ b/app/lib/screens/login_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../config.dart'; import '../theme/colors.dart'; import '../theme/text_styles.dart'; import '../providers/auth_provider.dart'; @@ -77,11 +80,7 @@ class LoginScreen extends ConsumerWidget { ), ], const SizedBox(height: 32), - Text( - 'By continuing, you agree to our Terms of Service\nand Privacy Policy.', - style: AppTextStyles.caption.copyWith(height: 1.5), - textAlign: TextAlign.center, - ), + const _LegalText(), if (kDebugMode) ...[ const SizedBox(height: 16), TextButton( @@ -105,6 +104,55 @@ class LoginScreen extends ConsumerWidget { } } +class _LegalText extends StatefulWidget { + const _LegalText(); + + @override + State<_LegalText> createState() => _LegalTextState(); +} + +class _LegalTextState extends State<_LegalText> { + late final TapGestureRecognizer _terms; + late final TapGestureRecognizer _privacy; + + @override + void initState() { + super.initState(); + _terms = TapGestureRecognizer()..onTap = () => _open(AppConfig.termsUrl); + _privacy = TapGestureRecognizer()..onTap = () => _open(AppConfig.privacyPolicyUrl); + } + + Future _open(String url) async { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + + @override + void dispose() { + _terms.dispose(); + _privacy.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final base = AppTextStyles.caption.copyWith(height: 1.5); + final link = base.copyWith(color: AppColors.primary, fontWeight: FontWeight.w600); + return Text.rich( + TextSpan( + style: base, + children: [ + const TextSpan(text: 'By continuing, you agree to our '), + TextSpan(text: 'Terms of Service', style: link, recognizer: _terms), + const TextSpan(text: ' and '), + TextSpan(text: 'Privacy Policy', style: link, recognizer: _privacy), + const TextSpan(text: '.'), + ], + ), + textAlign: TextAlign.center, + ); + } +} + class _SocialButton extends StatelessWidget { final VoidCallback onTap; final Widget icon; diff --git a/app/lib/screens/profile_screen.dart b/app/lib/screens/profile_screen.dart index db898a3..22ff344 100644 --- a/app/lib/screens/profile_screen.dart +++ b/app/lib/screens/profile_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../config.dart'; import '../providers/auth_provider.dart'; import '../services/api_service.dart'; import '../models/submission.dart'; import '../theme/colors.dart'; import '../theme/text_styles.dart'; import '../widgets/submission_card.dart'; +import 'login_screen.dart'; class ProfileScreen extends ConsumerStatefulWidget { const ProfileScreen({super.key}); @@ -139,6 +142,10 @@ class _ProfileScreenState extends ConsumerState { ), ), ), + const SliverPadding( + padding: EdgeInsets.fromLTRB(24, 32, 24, 0), + sliver: SliverToBoxAdapter(child: _SettingsCard()), + ), const SliverPadding(padding: EdgeInsets.only(bottom: 32)), ], ); @@ -489,3 +496,101 @@ class _ReferralCardState extends ConsumerState<_ReferralCard> { ); } } + +class _SettingsCard extends ConsumerWidget { + const _SettingsCard(); + + Future _open(String url) async { + final uri = Uri.parse(url); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + void _toLogin(BuildContext context) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (_) => false, + ); + } + + Future _signOut(BuildContext context, WidgetRef ref) async { + await ref.read(authProvider.notifier).signOut(); + if (context.mounted) _toLogin(context); + } + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete account'), + content: const Text('This permanently deletes your account and all your data. This cannot be undone.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete', style: TextStyle(color: AppColors.danger)), + ), + ], + ), + ); + if (confirmed != true) return; + final token = ref.read(authProvider).token ?? ''; + final ok = await ApiService.deleteAccount(token: token); + if (!context.mounted) return; + if (ok) { + await _signOut(context, ref); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not delete account. Please try again later.')), + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.border), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 12, offset: const Offset(0, 4))], + ), + child: Column( + children: [ + _SettingsRow(icon: Icons.shield_outlined, label: 'Privacy Policy', onTap: () => _open(AppConfig.privacyPolicyUrl)), + _SettingsRow(icon: Icons.description_outlined, label: 'Terms of Service', onTap: () => _open(AppConfig.termsUrl)), + _SettingsRow(icon: Icons.logout_rounded, label: 'Sign Out', onTap: () => _signOut(context, ref)), + _SettingsRow(icon: Icons.delete_outline_rounded, label: 'Delete Account', danger: true, onTap: () => _confirmDelete(context, ref)), + ], + ), + ); + } +} + +class _SettingsRow extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + final bool danger; + const _SettingsRow({required this.icon, required this.label, required this.onTap, this.danger = false}); + + @override + Widget build(BuildContext context) { + final color = danger ? AppColors.danger : AppColors.textPrimary; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + child: Row( + children: [ + Icon(icon, size: 20, color: danger ? AppColors.danger : AppColors.textSecondary), + const SizedBox(width: 14), + Expanded(child: Text(label, style: AppTextStyles.bodyMedium.copyWith(color: color, fontWeight: FontWeight.w600))), + const Icon(Icons.chevron_right_rounded, size: 20, color: AppColors.textTertiary), + ], + ), + ), + ); + } +} diff --git a/app/lib/services/api_service.dart b/app/lib/services/api_service.dart index f5b5144..a040a8b 100644 --- a/app/lib/services/api_service.dart +++ b/app/lib/services/api_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; +import '../config.dart'; import '../models/challenge.dart'; import '../models/submission.dart'; import '../models/user.dart'; @@ -12,7 +13,7 @@ class AuthTokens { } class ApiService { - static const _base = String.fromEnvironment('API_URL', defaultValue: 'http://localhost:8080'); + static const _base = AppConfig.apiBaseUrl; static String get baseUrl => _base; static void Function()? onUnauthorized; @@ -74,6 +75,19 @@ class ApiService { } catch (_) {} } + static Future deleteAccount({required String token}) async { + try { + final res = await http.delete( + Uri.parse('$_base/v1/account'), + headers: {'Authorization': 'Bearer $token'}, + ).timeout(const Duration(seconds: 10)); + if (res.statusCode == 401) { onUnauthorized?.call(); return false; } + return res.statusCode == 200 || res.statusCode == 204; + } catch (_) { + return false; + } + } + static Future> getChallenges() async { try { final res = await http.get(Uri.parse('$_base/v1/challenges')).timeout(const Duration(seconds: 5)); diff --git a/app/lib/services/token_store.dart b/app/lib/services/token_store.dart new file mode 100644 index 0000000..d8a9608 --- /dev/null +++ b/app/lib/services/token_store.dart @@ -0,0 +1,25 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class TokenStore { + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + + static const _accessKey = 'auth_token'; + static const _refreshKey = 'refresh_token'; + + static Future readAccess() => _storage.read(key: _accessKey); + + static Future readRefresh() => _storage.read(key: _refreshKey); + + static Future save(String access, String refresh) async { + await _storage.write(key: _accessKey, value: access); + await _storage.write(key: _refreshKey, value: refresh); + } + + static Future clear() async { + await _storage.delete(key: _accessKey); + await _storage.delete(key: _refreshKey); + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index efdf39f..b147b0a 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -18,6 +18,8 @@ dependencies: path_provider: ^2.1.5 geolocator: ^13.0.2 shared_preferences: ^2.3.0 + flutter_secure_storage: ^9.2.2 + url_launcher: ^6.3.1 dev_dependencies: flutter_test: diff --git a/backend/db/users.go b/backend/db/users.go index 9d56ab1..22eeef5 100644 --- a/backend/db/users.go +++ b/backend/db/users.go @@ -2,7 +2,8 @@ package db import ( "context" - "fmt" + + "github.com/jackc/pgx/v5" "github.com/abigailtech/humanloop/backend/models" ) @@ -32,16 +33,24 @@ func AddCredits(ctx context.Context, userID string, amount int) error { } func GetLeaderboard(ctx context.Context, period string) ([]models.User, error) { - var filter string + const base = `SELECT id, name, credits, submissions FROM users` + const order = ` ORDER BY credits DESC LIMIT 100` + const recent = ` WHERE id IN (SELECT DISTINCT user_id FROM submissions WHERE status='done' AND created_at > NOW() - ($1 || ' days')::interval)` + + var query string + var rows pgx.Rows + var err error switch period { case "week": - filter = "WHERE id IN (SELECT DISTINCT user_id FROM submissions WHERE status='done' AND created_at > NOW() - INTERVAL '7 days')" + query = base + recent + order + rows, err = Pool.Query(ctx, query, "7") case "month": - filter = "WHERE id IN (SELECT DISTINCT user_id FROM submissions WHERE status='done' AND created_at > NOW() - INTERVAL '30 days')" + query = base + recent + order + rows, err = Pool.Query(ctx, query, "30") + default: + query = base + order + rows, err = Pool.Query(ctx, query) } - rows, err := Pool.Query(ctx, fmt.Sprintf( - `SELECT id, name, credits, submissions FROM users %s ORDER BY credits DESC LIMIT 100`, filter, - )) if err != nil { return nil, err } diff --git a/backend/handlers/account.go b/backend/handlers/account.go index 9e3b02f..1a36aa1 100644 --- a/backend/handlers/account.go +++ b/backend/handlers/account.go @@ -8,7 +8,11 @@ import ( ) func DeleteAccount(w http.ResponseWriter, r *http.Request) { - userID := r.Context().Value(middleware.UserIDKey).(string) + userID, ok := middleware.UserID(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } if db.Pool == nil { http.Error(w, "db unavailable", http.StatusServiceUnavailable) return diff --git a/backend/handlers/admin.go b/backend/handlers/admin.go index c631dec..64291ce 100644 --- a/backend/handlers/admin.go +++ b/backend/handlers/admin.go @@ -105,6 +105,7 @@ func AdminRejectSubmission(w http.ResponseWriter, r *http.Request) { } func AdminTagSubmission(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) id := r.PathValue("id") var body struct { Tags []string `json:"tags"` diff --git a/backend/handlers/data/videos/test-user/2338ce6d-5e1c-4f51-bda6-1ceab6bf85b1.mp4 b/backend/handlers/data/videos/test-user/2338ce6d-5e1c-4f51-bda6-1ceab6bf85b1.mp4 deleted file mode 100644 index 773b8272f789a8b6b711fd088d245cceab961011..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 512 YcmZQzU=T?wsVvAXFfn2n1tTs503g-_8vp= 20 { @@ -121,8 +125,8 @@ func Submit(w http.ResponseWriter, r *http.Request) { title := challengeTitle(challengeID) difficulty := challengeDifficulty(challengeID) - userEmail := r.Context().Value(middleware.UserEmailKey).(string) - userName := r.Context().Value(middleware.UserNameKey).(string) + userEmail := middleware.UserEmail(r) + userName := middleware.UserName(r) submission := models.Submission{ ID: uuid.New().String(), diff --git a/backend/handlers/webhooks.go b/backend/handlers/webhooks.go index ae3e578..0214638 100644 --- a/backend/handlers/webhooks.go +++ b/backend/handlers/webhooks.go @@ -4,7 +4,9 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "net" "net/http" + "net/url" "github.com/google/uuid" @@ -12,6 +14,28 @@ import ( "github.com/abigailtech/humanloop/backend/models" ) +func validWebhookURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" { + return false + } + host := u.Hostname() + if host == "" { + return false + } + ips, err := net.LookupIP(host) + if err != nil || len(ips) == 0 { + return false + } + for _, ip := range ips { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return false + } + } + return true +} + func CreateWebhook(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 64*1024) var body struct { @@ -22,6 +46,10 @@ func CreateWebhook(w http.ResponseWriter, r *http.Request) { http.Error(w, "url required", http.StatusBadRequest) return } + if !validWebhookURL(body.URL) { + http.Error(w, "url must be a public https endpoint", http.StatusBadRequest) + return + } if db.Pool == nil { http.Error(w, "db unavailable", http.StatusServiceUnavailable) return diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index d9aaffd..04039be 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -98,6 +98,21 @@ func jwtSecret() string { return os.Getenv("JWT_SECRET") } +func UserID(r *http.Request) (string, bool) { + v, ok := r.Context().Value(UserIDKey).(string) + return v, ok && v != "" +} + +func UserEmail(r *http.Request) string { + v, _ := r.Context().Value(UserEmailKey).(string) + return v +} + +func UserName(r *http.Request) string { + v, _ := r.Context().Value(UserNameKey).(string) + return v +} + func stringClaim(claims jwt.MapClaims, key string) string { v, _ := claims[key].(string) return v diff --git a/backend/middleware/security.go b/backend/middleware/security.go index ae53d5d..255af8e 100644 --- a/backend/middleware/security.go +++ b/backend/middleware/security.go @@ -10,10 +10,11 @@ func Security(next http.Handler) http.Handler { origin := r.Header.Get("Origin") allowed := allowedOrigin() + w.Header().Add("Vary", "Origin") if origin != "" { if allowed == "*" || origin == allowed { w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key, X-Buyer-Key") } } @@ -27,6 +28,7 @@ func Security(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Content-Security-Policy", "default-src 'none'") + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") next.ServeHTTP(w, r) }) diff --git a/backend/pipeline/pipeline.go b/backend/pipeline/pipeline.go index e84335d..15f2839 100644 --- a/backend/pipeline/pipeline.go +++ b/backend/pipeline/pipeline.go @@ -6,11 +6,13 @@ import ( "context" "crypto/hmac" "crypto/sha256" + "crypto/tls" "encoding/hex" "encoding/json" "fmt" "io" "log" + "net" "net/http" "os" "os/exec" @@ -331,7 +333,34 @@ func qualityScore(record map[string]any) float64 { return (coverage + fpsScore + durationScore) / 3.0 } -var webhookClient = &http.Client{Timeout: 10 * time.Second} +var webhookClient = &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext, + DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil || len(ips) == 0 { + return nil, fmt.Errorf("webhook host resolution failed") + } + var target net.IP + for _, ip := range ips { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return nil, fmt.Errorf("blocked non-public webhook address") + } + if target == nil { + target = ip + } + } + d := &net.Dialer{Timeout: 5 * time.Second} + return tls.DialWithDialer(d, network, net.JoinHostPort(target.String(), port), &tls.Config{ServerName: host}) + }, + }, +} func dispatchWebhooks(submissionID, challengeID string, qs float64) { if db.Pool == nil { diff --git a/backend/storage/s3_store.go b/backend/storage/s3_store.go index 7ab56fe..c38fea8 100644 --- a/backend/storage/s3_store.go +++ b/backend/storage/s3_store.go @@ -2,6 +2,7 @@ package storage import ( "context" + "fmt" "io" "os" "strings" @@ -44,7 +45,14 @@ func (s *S3Store) BaseDir() string { } func (s *S3Store) PresignGetHMDF(hmdfPath string, ttl time.Duration) (string, error) { - key := strings.TrimPrefix(hmdfPath, "s3://"+s.bucket+"/") + prefix := "s3://" + s.bucket + "/" + if !strings.HasPrefix(hmdfPath, prefix) { + return "", fmt.Errorf("invalid hmdf path") + } + key := strings.TrimPrefix(hmdfPath, prefix) + if key == "" || strings.HasPrefix(key, "/") || strings.Contains(key, "..") { + return "", fmt.Errorf("invalid hmdf key") + } presignClient := s3.NewPresignClient(s.client) req, err := presignClient.PresignGetObject(context.Background(), &s3.GetObjectInput{ Bucket: &s.bucket, diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..84fc445 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,7 @@ +/node_modules +/.next +/out +/build +next-env.d.ts +.env*.local +.DS_Store diff --git a/web/next.config.ts b/web/next.config.ts index 55b5c5b..8536109 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,10 +1,5 @@ import type { NextConfig } from 'next' -const config: NextConfig = { - env: { - API_URL: process.env.API_URL ?? 'http://localhost:8080', - API_KEY: process.env.API_KEY ?? '', - }, -} +const config: NextConfig = {} export default config diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..3db3204 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1012 @@ +{ + "name": "humanloop-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "humanloop-web", + "version": "0.1.0", + "dependencies": { + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz", + "integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz", + "integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz", + "integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz", + "integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz", + "integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz", + "integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz", + "integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz", + "integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", + "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz", + "integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.19", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.19", + "@next/swc-darwin-x64": "15.5.19", + "@next/swc-linux-arm64-gnu": "15.5.19", + "@next/swc-linux-arm64-musl": "15.5.19", + "@next/swc-linux-x64-gnu": "15.5.19", + "@next/swc-linux-x64-musl": "15.5.19", + "@next/swc-win32-arm64-msvc": "15.5.19", + "@next/swc-win32-x64-msvc": "15.5.19", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/src/app/challenges/page.tsx b/web/src/app/challenges/page.tsx deleted file mode 100644 index f709592..0000000 --- a/web/src/app/challenges/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Link from 'next/link' -import { getChallenges } from '@/lib/api' -import ChallengeList from '@/components/ChallengeList' - -export default async function ChallengesPage() { - const challenges = await getChallenges() - - return ( -
-
- ← Dashboard -

Challenges

-
- -
- ) -} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index ef95c7e..8503653 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,10 +1,13 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; } + body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - background: #f5f5f7; - color: #1d1d1f; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #ffffff; + color: #0a0a0a; min-height: 100vh; + -webkit-font-smoothing: antialiased; } a { color: inherit; text-decoration: none; } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 332e0b2..cd4001b 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -2,8 +2,8 @@ import type { Metadata } from 'next' import './globals.css' export const metadata: Metadata = { - title: 'HumanLoop Admin', - description: 'Challenge management dashboard', + title: 'HumanLoop — Film challenges, train robots', + description: 'HumanLoop is a mobile app where everyday people film short physical challenges to create training data that teaches robots real-world skills. Download on iOS and Android.', } export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 3f60c88..08c5d2e 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,45 +1,19 @@ -import Link from 'next/link' -import { getStats } from '@/lib/api' - -export default async function Home() { - const stats = await getStats() +import Nav from '@/components/Nav' +import Hero from '@/components/Hero' +import Features from '@/components/Features' +import Steps from '@/components/Steps' +import Download from '@/components/Download' +import Footer from '@/components/Footer' +export default function Home() { return ( -
-
-

HumanLoop

-

Admin Dashboard

-
- -
- {[ - { label: 'Total Submissions', value: stats?.total_submissions ?? '—' }, - { label: 'Verified', value: stats?.verified ?? '—' }, - { label: 'Synthetic Rejected', value: stats?.synthetic ?? '—' }, - { label: 'Credits Issued', value: stats?.credits_issued ?? '—' }, - { label: 'Challenges', value: stats?.challenges ?? '—' }, - ].map(s => ( -
-
{s.value}
-
{s.label}
-
- ))} -
- -
- - Manage Challenges - - - View Stats - -
+
+
) } diff --git a/web/src/app/stats/page.tsx b/web/src/app/stats/page.tsx deleted file mode 100644 index 44909c3..0000000 --- a/web/src/app/stats/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import Link from 'next/link' -import { getStats } from '@/lib/api' - -export default async function StatsPage() { - const stats = await getStats() - - const rows = stats ? [ - { label: 'Total Submissions', value: stats.total_submissions }, - { label: 'Verified', value: stats.verified }, - { label: 'Synthetic Rejected', value: stats.synthetic }, - { label: 'Failed', value: stats.failed }, - { label: 'Credits Issued', value: stats.credits_issued }, - { label: 'Active Challenges', value: stats.challenges }, - { label: 'Acceptance Rate', value: stats.total_submissions > 0 ? `${Math.round(stats.verified / stats.total_submissions * 100)}%` : '—' }, - ] : [] - - return ( -
-
- ← Dashboard -

Stats

-
- - {!stats ? ( -

Stats unavailable — backend may be offline or API_KEY not set.

- ) : ( -
- {rows.map((row, i) => ( -
- {row.label} - {row.value} -
- ))} -
- )} -
- ) -} diff --git a/web/src/components/ChallengeForm.tsx b/web/src/components/ChallengeForm.tsx deleted file mode 100644 index f901bec..0000000 --- a/web/src/components/ChallengeForm.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client' - -import { useState } from 'react' -import type { Challenge } from '@/lib/api' - -interface Props { - initial?: Partial - onSave: (data: { title: string; description: string; difficulty: string }) => Promise - onCancel: () => void -} - -export default function ChallengeForm({ initial, onSave, onCancel }: Props) { - const [title, setTitle] = useState(initial?.title ?? '') - const [description, setDescription] = useState(initial?.description ?? '') - const [difficulty, setDifficulty] = useState(initial?.difficulty ?? 'Easy') - const [saving, setSaving] = useState(false) - const [error, setError] = useState('') - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - setSaving(true) - setError('') - try { - await onSave({ title, description, difficulty }) - } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Failed to save') - setSaving(false) - } - } - - const inputStyle: React.CSSProperties = { - width: '100%', padding: '10px 14px', borderRadius: 10, - border: '1px solid #e5e5ea', fontSize: 15, outline: 'none', - fontFamily: 'inherit', - } - - return ( -
-
- - setTitle(e.target.value)} required /> -
-
- -