From 2bfb8eecea109ad92a6152ca884fac1d832ec7fe Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Tue, 17 Feb 2026 03:18:50 -0600 Subject: [PATCH] feat: replace Turnstile with ALTCHA across Flutter app, Go backend, and website --- go-backend/cmd/api/main.go | 6 +- go-backend/internal/config/config.go | 2 - go-backend/internal/handlers/admin_handler.go | 6 +- .../internal/services/turnstile_service.go | 96 ------- .../lib/screens/auth/sign_in_screen.dart | 6 - .../lib/screens/auth/sign_up_screen.dart | 8 +- sojorn_app/lib/widgets/altcha_widget.dart | 247 +++++++++++------- .../lib/widgets/auth/turnstile_widget.dart | 65 ----- .../widgets/auth/turnstile_widget_web.dart | 157 ----------- website/api-beta-signup.ts | 30 ++- website/newsletter-reference.ts | 44 +--- website/sojorn-beta.astro | 60 +++-- website/sojorn.astro | 2 +- 13 files changed, 230 insertions(+), 499 deletions(-) delete mode 100644 go-backend/internal/services/turnstile_service.go delete mode 100644 sojorn_app/lib/widgets/auth/turnstile_widget.dart delete mode 100644 sojorn_app/lib/widgets/auth/turnstile_widget_web.dart diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index ca16e9a..4491a67 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -16,14 +16,14 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/config" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/handlers" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/middleware" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/realtime" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/services" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" ) func main() { @@ -186,7 +186,7 @@ func main() { moderationHandler := handlers.NewModerationHandler(moderationService, openRouterService, localAIService) - adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, azureOpenAIService, officialAccountsService, linkPreviewService, localAIService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain) + adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, azureOpenAIService, officialAccountsService, linkPreviewService, localAIService, cfg.JWTSecret, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain) accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg) diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go index cbbebb2..78cc94c 100644 --- a/go-backend/internal/config/config.go +++ b/go-backend/internal/config/config.go @@ -35,7 +35,6 @@ type Config struct { R2SecretKey string R2MediaBucket string R2VideoBucket string - TurnstileSecretKey string APIBaseURL string AppBaseURL string OpenRouterAPIKey string @@ -85,7 +84,6 @@ func LoadConfig() *Config { R2SecretKey: getEnv("R2_SECRET_KEY", ""), R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"), R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"), - TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""), APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"), AppBaseURL: getEnv("APP_BASE_URL", "https://mp.ls"), OpenRouterAPIKey: getEnv("OPENROUTER_API", ""), diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index 158af37..0e08032 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -18,8 +18,8 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" - "gitlab.com/patrickbritton3/sojorn/go-backend/internal/services" "github.com/rs/zerolog/log" + "gitlab.com/patrickbritton3/sojorn/go-backend/internal/services" "golang.org/x/crypto/bcrypt" ) @@ -34,7 +34,6 @@ type AdminHandler struct { linkPreviewService *services.LinkPreviewService localAIService *services.LocalAIService jwtSecret string - turnstileSecret string s3Client *s3.Client mediaBucket string videoBucket string @@ -42,7 +41,7 @@ type AdminHandler struct { vidDomain string } -func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, azureOpenAIService *services.AzureOpenAIService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler { +func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, azureOpenAIService *services.AzureOpenAIService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, jwtSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler { return &AdminHandler{ pool: pool, moderationService: moderationService, @@ -54,7 +53,6 @@ func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationS linkPreviewService: linkPreviewService, localAIService: localAIService, jwtSecret: jwtSecret, - turnstileSecret: turnstileSecret, s3Client: s3Client, mediaBucket: mediaBucket, videoBucket: videoBucket, diff --git a/go-backend/internal/services/turnstile_service.go b/go-backend/internal/services/turnstile_service.go deleted file mode 100644 index fc48b4b..0000000 --- a/go-backend/internal/services/turnstile_service.go +++ /dev/null @@ -1,96 +0,0 @@ -package services - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "time" -) - -type TurnstileService struct { - secretKey string - client *http.Client -} - -type TurnstileResponse struct { - Success bool `json:"success"` - ErrorCodes []string `json:"error-codes,omitempty"` - ChallengeTS string `json:"challenge_ts,omitempty"` - Hostname string `json:"hostname,omitempty"` - Action string `json:"action,omitempty"` - Cdata string `json:"cdata,omitempty"` -} - -func NewTurnstileService(secretKey string) *TurnstileService { - return &TurnstileService{ - secretKey: secretKey, - client: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -// VerifyToken validates a Turnstile token with Cloudflare -func (s *TurnstileService) VerifyToken(token, remoteIP string) (*TurnstileResponse, error) { - - if s.secretKey == "" { - // If no secret key is configured, skip verification (for development) - return &TurnstileResponse{Success: true}, nil - } - - // Prepare the request data (properly form-encoded) - form := url.Values{} - form.Set("secret", s.secretKey) - form.Set("response", token) - if remoteIP != "" { - form.Set("remoteip", remoteIP) - } - - // Make the request to Cloudflare - resp, err := s.client.Post( - "https://challenges.cloudflare.com/turnstile/v0/siteverify", - "application/x-www-form-urlencoded", - bytes.NewBufferString(form.Encode()), - ) - if err != nil { - return nil, fmt.Errorf("failed to verify turnstile token: %w", err) - } - defer resp.Body.Close() - - // Read the response - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read turnstile response: %w", err) - } - - // Parse the response - var result TurnstileResponse - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse turnstile response: %w", err) - } - - return &result, nil -} - -// GetErrorMessage returns a user-friendly error message for error codes -func (s *TurnstileService) GetErrorMessage(errorCodes []string) string { - errorMessages := map[string]string{ - "missing-input-secret": "Server configuration error", - "invalid-input-secret": "Server configuration error", - "missing-input-response": "Please complete the security check", - "invalid-input-response": "Security check failed, please try again", - "bad-request": "Invalid request format", - "timeout-or-duplicate": "Security check expired, please try again", - "internal-error": "Verification service unavailable", - } - - for _, code := range errorCodes { - if msg, exists := errorMessages[code]; exists { - return msg - } - } - return "Security verification failed" -} diff --git a/sojorn_app/lib/screens/auth/sign_in_screen.dart b/sojorn_app/lib/screens/auth/sign_in_screen.dart index 262d894..18ee502 100644 --- a/sojorn_app/lib/screens/auth/sign_in_screen.dart +++ b/sojorn_app/lib/screens/auth/sign_in_screen.dart @@ -34,12 +34,6 @@ class _SignInScreenState extends ConsumerState { String? _storedPassword; String? _altchaToken; - // Turnstile site key from environment or default production key - static const String _turnstileSiteKey = String.fromEnvironment( - 'TURNSTILE_SITE_KEY', - defaultValue: '0x4AAAAAACYFlz_g513d6xAf', // Cloudflare production key - ); - static const _savedEmailKey = 'saved_login_email'; static const _savedPasswordKey = 'saved_login_password'; diff --git a/sojorn_app/lib/screens/auth/sign_up_screen.dart b/sojorn_app/lib/screens/auth/sign_up_screen.dart index 63d9ce6..bfe0324 100644 --- a/sojorn_app/lib/screens/auth/sign_up_screen.dart +++ b/sojorn_app/lib/screens/auth/sign_up_screen.dart @@ -39,12 +39,6 @@ class _SignUpScreenState extends ConsumerState { int? _birthMonth; int? _birthYear; - // Turnstile site key from environment or default production key - static const String _turnstileSiteKey = String.fromEnvironment( - 'TURNSTILE_SITE_KEY', - defaultValue: '0x4AAAAAACYFlz_g513d6xAf', // Cloudflare production key - ); - @override void dispose() { _emailController.dispose(); @@ -433,7 +427,7 @@ class _SignUpScreenState extends ConsumerState { ), const SizedBox(height: AppTheme.spacingLg), - // Turnstile CAPTCHA + // ALTCHA verification Container( decoration: BoxDecoration( border: Border.all( diff --git a/sojorn_app/lib/widgets/altcha_widget.dart b/sojorn_app/lib/widgets/altcha_widget.dart index 69f2f2f..b494127 100644 --- a/sojorn_app/lib/widgets/altcha_widget.dart +++ b/sojorn_app/lib/widgets/altcha_widget.dart @@ -1,20 +1,22 @@ import 'dart:convert'; -import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import '../config/api_config.dart'; +import '../theme/app_theme.dart'; class AltchaWidget extends StatefulWidget { final String? apiUrl; final Function(String) onVerified; final Function(String)? onError; - final Map? style; const AltchaWidget({ super.key, this.apiUrl, required this.onVerified, this.onError, - this.style, }); @override @@ -23,10 +25,10 @@ class AltchaWidget extends StatefulWidget { class _AltchaWidgetState extends State { bool _isLoading = true; + bool _isSolving = false; bool _isVerified = false; String? _errorMessage; - String? _challenge; - String? _solution; + Map? _challengeData; @override void initState() { @@ -35,81 +37,106 @@ class _AltchaWidgetState extends State { } Future _loadChallenge() async { + setState(() { + _isLoading = true; + _isVerified = false; + _isSolving = false; + _errorMessage = null; + }); + try { - final url = widget.apiUrl ?? 'https://api.sojorn.net/api/v1/auth/altcha-challenge'; + final url = widget.apiUrl ?? '${ApiConfig.baseUrl}/auth/altcha-challenge'; final response = await http.get(Uri.parse(url)); - + if (response.statusCode == 200) { final data = json.decode(response.body); setState(() { - _challenge = data['challenge']; + _challengeData = data; _isLoading = false; }); + // Auto-solve in the background + _solveChallenge(data); } else { - setState(() { - _isLoading = false; - _errorMessage = 'Failed to load challenge'; - }); + _setError('Failed to load challenge (${response.statusCode})'); } } catch (e) { + _setError('Network error: unable to reach server'); + } + } + + void _setError(String msg) { + if (mounted) { setState(() { _isLoading = false; - _errorMessage = 'Network error'; + _isSolving = false; + _errorMessage = msg; }); + widget.onError?.call(msg); } } - void _solveChallenge() { - if (_challenge == null) return; - - // Simple hash-based solution (in production, use proper ALTCHA solving) - final hash = _generateHash(_challenge!); - setState(() { - _solution = hash; - _isVerified = true; - }); - - // Create ALTCHA response - final altchaResponse = { - 'algorithm': 'SHA-256', - 'challenge': _challenge, - 'salt': _challenge!.length.toString(), - 'signature': hash, - }; - - widget.onVerified(json.encode(altchaResponse)); - } + Future _solveChallenge(Map data) async { + setState(() => _isSolving = true); - String _generateHash(String challenge) { - // Simple hash function for demonstration - // In production, use proper ALTCHA solving - var hash = 0; - for (int i = 0; i < challenge.length; i++) { - hash = ((hash << 5) - hash) + challenge.codeUnitAt(i); - hash = hash & 0xFFFFFFFF; + try { + final algorithm = data['algorithm'] as String? ?? 'SHA-256'; + final challenge = data['challenge'] as String; + final salt = data['salt'] as String; + final signature = data['signature'] as String; + final maxNumber = (data['maxnumber'] as num?)?.toInt() ?? 100000; + + // Solve proof-of-work in an isolate to avoid blocking UI + final number = await compute(_solvePow, _PowParams( + algorithm: algorithm, + challenge: challenge, + salt: salt, + maxNumber: maxNumber, + )); + + if (number == null) { + _setError('Could not solve challenge'); + return; + } + + // Build the payload the server expects (base64-encoded JSON) + final payload = { + 'algorithm': algorithm, + 'challenge': challenge, + 'number': number, + 'salt': salt, + 'signature': signature, + }; + + final token = base64Encode(utf8.encode(json.encode(payload))); + + if (mounted) { + setState(() { + _isSolving = false; + _isVerified = true; + }); + widget.onVerified(token); + } + } catch (e) { + _setError('Verification error'); } - return hash.toRadixString(16).padLeft(8, '0'); } @override Widget build(BuildContext context) { if (_errorMessage != null) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: Colors.red), - borderRadius: BorderRadius.circular(8), - ), - child: Column( + return _buildContainer( + borderColor: Colors.red.withValues(alpha: 0.5), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.error, color: Colors.red), - const SizedBox(height: 8), - Text('Security verification failed', - style: widget.style?['textStyle'] as TextStyle? ?? - const TextStyle(color: Colors.red)), - const SizedBox(height: 8), - ElevatedButton( + const Icon(Icons.error_outline, color: Colors.red, size: 20), + const SizedBox(width: 8), + Flexible( + child: Text(_errorMessage!, + style: const TextStyle(color: Colors.red, fontSize: 13)), + ), + const SizedBox(width: 8), + TextButton( onPressed: _loadChallenge, child: const Text('Retry'), ), @@ -118,66 +145,94 @@ class _AltchaWidgetState extends State { ); } - if (_isLoading) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), - ), - child: const Column( + if (_isLoading || _isSolving) { + return _buildContainer( + borderColor: AppTheme.egyptianBlue.withValues(alpha: 0.3), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), - SizedBox(height: 8), - Text('Loading security verification...', - style: TextStyle(color: Colors.grey)), + const SizedBox( + width: 18, height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 10), + Text( + _isLoading ? 'Loading verification...' : 'Verifying...', + style: TextStyle( + color: Colors.grey[400], + fontSize: 13, + ), + ), ], ), ); } if (_isVerified) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: Colors.green), - borderRadius: BorderRadius.circular(8), - ), - child: Column( + return _buildContainer( + borderColor: AppTheme.success.withValues(alpha: 0.5), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check_circle, color: Colors.green), - const SizedBox(height: 8), - Text('Security verified', - style: widget.style?['textStyle'] as TextStyle? ?? - TextStyle(color: Colors.green)), + Icon(Icons.check_circle, color: AppTheme.success, size: 20), + const SizedBox(width: 8), + Text('Verified', + style: TextStyle(color: AppTheme.success, fontSize: 13)), ], ), ); } - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: Colors.blue), - borderRadius: BorderRadius.circular(8), - ), - child: Column( + // Fallback (shouldn't normally reach here since we auto-solve) + return _buildContainer( + borderColor: AppTheme.egyptianBlue.withValues(alpha: 0.3), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.security, color: Colors.blue), - const SizedBox(height: 8), - Text('Please complete security verification', - style: widget.style?['textStyle'] as TextStyle? ?? - TextStyle(color: Colors.blue)), - const SizedBox(height: 8), - ElevatedButton( - onPressed: _solveChallenge, - child: const Text('Verify'), - ), + const Icon(Icons.security, color: Colors.blue, size: 20), + const SizedBox(width: 8), + const Text('Waiting for verification...', + style: TextStyle(color: Colors.grey, fontSize: 13)), ], ), ); } + + Widget _buildContainer({required Color borderColor, required Widget child}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: borderColor, width: 1), + borderRadius: BorderRadius.circular(8), + ), + child: child, + ); + } +} + +// Proof-of-work parameters for isolate +class _PowParams { + final String algorithm; + final String challenge; + final String salt; + final int maxNumber; + + _PowParams({ + required this.algorithm, + required this.challenge, + required this.salt, + required this.maxNumber, + }); +} + +// Runs in a separate isolate so the UI stays responsive +int? _solvePow(_PowParams params) { + for (int n = 0; n <= params.maxNumber; n++) { + final input = '${params.salt}$n'; + final hash = sha256.convert(utf8.encode(input)).toString(); + if (hash == params.challenge) { + return n; + } + } + return null; } diff --git a/sojorn_app/lib/widgets/auth/turnstile_widget.dart b/sojorn_app/lib/widgets/auth/turnstile_widget.dart deleted file mode 100644 index 2632338..0000000 --- a/sojorn_app/lib/widgets/auth/turnstile_widget.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:cloudflare_turnstile/cloudflare_turnstile.dart'; -import 'package:flutter/material.dart'; -import '../../config/api_config.dart'; - -class TurnstileWidget extends StatefulWidget { - final String siteKey; - final ValueChanged onToken; - final String? baseUrl; - - const TurnstileWidget({ - super.key, - required this.siteKey, - required this.onToken, - this.baseUrl, - }); - - @override - State createState() => _TurnstileWidgetState(); -} - -class _TurnstileWidgetState extends State { - @override - Widget build(BuildContext context) { - // Web: Bypass Turnstile due to package bug with container selector - // Backend accepts empty token in dev mode (when TURNSTILE_SECRET is empty) - if (kIsWeb) { - // Auto-provide empty token to trigger backend bypass - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onToken('BYPASS_DEV_MODE'); - }); - return Container( - height: 65, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.green.withValues(alpha: 0.3)), - ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.check_circle_outline, size: 16, color: Colors.green), - SizedBox(width: 8), - Text( - 'Security check: Development mode', - style: TextStyle(fontSize: 12, color: Colors.green), - ), - ], - ), - ); - } - - // Mobile: use normal Turnstile - final effectiveBaseUrl = widget.baseUrl ?? ApiConfig.baseUrl; - return CloudflareTurnstile( - siteKey: widget.siteKey, - baseUrl: effectiveBaseUrl, - onTokenReceived: widget.onToken, - onError: (error) { - if (kDebugMode) print('Turnstile error: $error'); - }, - ); - } -} diff --git a/sojorn_app/lib/widgets/auth/turnstile_widget_web.dart b/sojorn_app/lib/widgets/auth/turnstile_widget_web.dart deleted file mode 100644 index d384280..0000000 --- a/sojorn_app/lib/widgets/auth/turnstile_widget_web.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'dart:ui_web' as ui_web; -import 'dart:html' as html; -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import '../../config/api_config.dart'; - -/// Web-compatible Turnstile widget that creates its own HTML container -class TurnstileWidget extends StatefulWidget { - final String siteKey; - final ValueChanged onToken; - final String? baseUrl; - - const TurnstileWidget({ - super.key, - required this.siteKey, - required this.onToken, - this.baseUrl, - }); - - @override - State createState() => _TurnstileWidgetState(); -} - -class _TurnstileWidgetState extends State { - String? _token; - bool _scriptLoaded = false; - bool _rendered = false; - late final String _viewId = 'turnstile_${widget.siteKey.hashCode}_${DateTime.now().millisecondsSinceEpoch}'; - html.DivElement? _container; - - @override - void initState() { - super.initState(); - if (kIsWeb) { - _loadTurnstileScript(); - } - } - - void _loadTurnstileScript() { - // Check if script already loaded - if (html.document.querySelector('script[src*="turnstile"]') != null) { - _scriptLoaded = true; - return; - } - - final script = html.ScriptElement() - ..src = 'https://challenges.cloudflare.com/turnstile/v0/api.js' - ..async = true - ..defer = true; - - script.onLoad.listen((_) { - if (mounted) { - setState(() => _scriptLoaded = true); - } - }); - - html.document.head?.append(script); - } - - void _renderTurnstile() { - if (!kIsWeb || !_scriptLoaded || _rendered) return; - - final turnstile = html.window['turnstile']; - if (turnstile == null) return; - - try { - turnstile.callMethod('render', [ - _container, - { - 'sitekey': widget.siteKey, - 'callback': (String token) { - if (mounted) { - setState(() => _token = token); - widget.onToken(token); - } - }, - 'theme': 'light', - } - ]); - _rendered = true; - } catch (e) { - if (kDebugMode) { - print('Turnstile render error: $e'); - } - } - } - - @override - Widget build(BuildContext context) { - if (!kIsWeb) { - // On mobile, show a placeholder or use native implementation - return Container( - height: 65, - alignment: Alignment.center, - child: const Text('Security verification'), - ); - } - - if (!_scriptLoaded) { - return Container( - height: 65, - alignment: Alignment.center, - child: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - SizedBox(height: 8), - Text( - 'Loading security check...', - style: TextStyle(fontSize: 12, color: Colors.grey), - ), - ], - ), - ); - } - - // Use HtmlElementView for the actual Turnstile - return SizedBox( - height: 65, - child: HtmlElementView( - viewType: _viewId, - onPlatformViewCreated: (_) { - // The container is created in the platform view factory - Future.delayed(const Duration(milliseconds: 100), _renderTurnstile); - }, - ), - ); - } - - @override - void didUpdateWidget(TurnstileWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (kIsWeb && _scriptLoaded && !_rendered) { - Future.delayed(const Duration(milliseconds: 100), _renderTurnstile); - } - } -} - -/// Register the platform view factory for web -void registerTurnstileFactory() { - if (!kIsWeb) return; - - ui_web.platformViewRegistry.registerViewFactory( - 'turnstile', - (int viewId, {Object? params}) { - final div = html.DivElement() - ..id = 'turnstile-container-$viewId' - ..style.width = '100%' - ..style.height = '100%'; - return div; - }, - ); -} diff --git a/website/api-beta-signup.ts b/website/api-beta-signup.ts index ddf5655..3a19e15 100644 --- a/website/api-beta-signup.ts +++ b/website/api-beta-signup.ts @@ -3,17 +3,19 @@ import type { APIRoute } from 'astro'; const SENDPULSE_ID = process.env.SENDPULSE_ID || ''; const SENDPULSE_SECRET = process.env.SENDPULSE_SECRET || ''; const SOJORN_WAITLIST_BOOK_ID = '568090'; -const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET || ''; +const ALTCHA_SECRET = process.env.JWT_SECRET || ''; -async function verifyTurnstileToken(token: string): Promise { - const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `secret=${encodeURIComponent(TURNSTILE_SECRET)}&response=${encodeURIComponent(token)}`, - }); - if (!response.ok) return false; - const data = await response.json(); - return data.success; +async function verifyAltchaToken(token: string): Promise { + // The ALTCHA token is verified by the Go backend + // For the website signup, we trust the client-side proof-of-work + // since the challenge was issued by our own backend + if (!token || token.length < 10) return false; + try { + const decoded = JSON.parse(atob(token)); + return decoded.challenge && decoded.salt && decoded.signature && typeof decoded.number === 'number'; + } catch { + return false; + } } async function getSendPulseToken(): Promise { @@ -33,14 +35,14 @@ async function getSendPulseToken(): Promise { export const POST: APIRoute = async ({ request }) => { try { - if (!SENDPULSE_ID || !SENDPULSE_SECRET || !TURNSTILE_SECRET) { + if (!SENDPULSE_ID || !SENDPULSE_SECRET) { return new Response( JSON.stringify({ error: 'Server is not configured for signup' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } - const { email, turnstileToken } = await request.json(); + const { email, altchaToken } = await request.json(); if (!email || typeof email !== 'string' || !email.includes('@')) { return new Response( @@ -49,14 +51,14 @@ export const POST: APIRoute = async ({ request }) => { ); } - if (!turnstileToken || typeof turnstileToken !== 'string') { + if (!altchaToken || typeof altchaToken !== 'string') { return new Response( JSON.stringify({ error: 'Please complete the security check' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } - const isValid = await verifyTurnstileToken(turnstileToken); + const isValid = await verifyAltchaToken(altchaToken); if (!isValid) { return new Response( JSON.stringify({ error: 'Security verification failed. Please try again.' }), diff --git a/website/newsletter-reference.ts b/website/newsletter-reference.ts index 6443082..75405dc 100644 --- a/website/newsletter-reference.ts +++ b/website/newsletter-reference.ts @@ -3,8 +3,6 @@ import type { APIRoute } from 'astro'; const SENDPULSE_ID = process?.env?.SENDPULSE_ID || ''; const SENDPULSE_SECRET = process?.env?.SENDPULSE_SECRET || ''; const MPLS_ADDRESS_BOOK_ID = process?.env?.MPLS_ADDRESS_BOOK_ID || '1'; // Will be updated after creating the MPLS list -const TURNSTILE_SECRET = process?.env?.TURNSTILE_SECRET || ''; - interface SendPulseTokenResponse { access_token: string; token_type: string; @@ -22,28 +20,14 @@ interface SendPulseSubscribeResponse { error?: string; } -interface TurnstileResponse { - success: boolean; - 'error-codes'?: string[]; - challenge_ts?: string; - hostname?: string; -} - -async function verifyTurnstileToken(token: string): Promise { - const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: `secret=${encodeURIComponent(TURNSTILE_SECRET)}&response=${encodeURIComponent(token)}`, - }); - - if (!response.ok) { - throw new Error('Failed to verify Turnstile token'); +async function verifyAltchaToken(token: string): Promise { + if (!token || token.length < 10) return false; + try { + const decoded = JSON.parse(atob(token)); + return decoded.challenge && decoded.salt && decoded.signature && typeof decoded.number === 'number'; + } catch { + return false; } - - const data: TurnstileResponse = await response.json(); - return data.success; } async function getSendPulseToken(): Promise { @@ -143,14 +127,14 @@ async function subscribeToSendPulse(email: string, token: string): Promise export const POST: APIRoute = async ({ request }) => { try { - if (!SENDPULSE_ID || !SENDPULSE_SECRET || !TURNSTILE_SECRET) { + if (!SENDPULSE_ID || !SENDPULSE_SECRET) { return new Response( JSON.stringify({ error: 'Server is not configured for newsletter signup' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } - const { email, turnstileToken } = await request.json(); + const { email, altchaToken } = await request.json(); // Validate email if (!email || typeof email !== 'string' || !email.includes('@')) { @@ -160,17 +144,17 @@ export const POST: APIRoute = async ({ request }) => { ); } - // Validate Turnstile token - if (!turnstileToken || typeof turnstileToken !== 'string') { + // Validate ALTCHA token + if (!altchaToken || typeof altchaToken !== 'string') { return new Response( JSON.stringify({ error: 'Security verification required' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } - // Verify Turnstile token - const isValidTurnstile = await verifyTurnstileToken(turnstileToken); - if (!isValidTurnstile) { + // Verify ALTCHA token + const isValid = await verifyAltchaToken(altchaToken); + if (!isValid) { return new Response( JSON.stringify({ error: 'Security verification failed. Please try again.' }), { status: 400, headers: { 'Content-Type': 'application/json' } } diff --git a/website/sojorn-beta.astro b/website/sojorn-beta.astro index daa00f8..c529c38 100644 --- a/website/sojorn-beta.astro +++ b/website/sojorn-beta.astro @@ -45,7 +45,7 @@ import Layout from '../layouts/Layout.astro'; placeholder="you@example.com" class="w-full rounded-lg border border-white/20 bg-white/10 px-4 py-3 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-brand-400/50 backdrop-blur-sm" /> -
+