feat: replace Turnstile with ALTCHA across Flutter app, Go backend, and website

This commit is contained in:
Patrick Britton 2026-02-17 03:18:50 -06:00
parent 602a139349
commit 2bfb8eecea
13 changed files with 230 additions and 499 deletions

View file

@ -16,14 +16,14 @@ import (
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool" "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/config"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/handlers" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/handlers"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/middleware" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/middleware"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/realtime" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/realtime"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services" "gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
) )
func main() { func main() {
@ -186,7 +186,7 @@ func main() {
moderationHandler := handlers.NewModerationHandler(moderationService, openRouterService, localAIService) 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) accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)

View file

@ -35,7 +35,6 @@ type Config struct {
R2SecretKey string R2SecretKey string
R2MediaBucket string R2MediaBucket string
R2VideoBucket string R2VideoBucket string
TurnstileSecretKey string
APIBaseURL string APIBaseURL string
AppBaseURL string AppBaseURL string
OpenRouterAPIKey string OpenRouterAPIKey string
@ -85,7 +84,6 @@ func LoadConfig() *Config {
R2SecretKey: getEnv("R2_SECRET_KEY", ""), R2SecretKey: getEnv("R2_SECRET_KEY", ""),
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"), R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"), R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""),
APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"), APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"),
AppBaseURL: getEnv("APP_BASE_URL", "https://mp.ls"), AppBaseURL: getEnv("APP_BASE_URL", "https://mp.ls"),
OpenRouterAPIKey: getEnv("OPENROUTER_API", ""), OpenRouterAPIKey: getEnv("OPENROUTER_API", ""),

View file

@ -18,8 +18,8 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -34,7 +34,6 @@ type AdminHandler struct {
linkPreviewService *services.LinkPreviewService linkPreviewService *services.LinkPreviewService
localAIService *services.LocalAIService localAIService *services.LocalAIService
jwtSecret string jwtSecret string
turnstileSecret string
s3Client *s3.Client s3Client *s3.Client
mediaBucket string mediaBucket string
videoBucket string videoBucket string
@ -42,7 +41,7 @@ type AdminHandler struct {
vidDomain string 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{ return &AdminHandler{
pool: pool, pool: pool,
moderationService: moderationService, moderationService: moderationService,
@ -54,7 +53,6 @@ func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationS
linkPreviewService: linkPreviewService, linkPreviewService: linkPreviewService,
localAIService: localAIService, localAIService: localAIService,
jwtSecret: jwtSecret, jwtSecret: jwtSecret,
turnstileSecret: turnstileSecret,
s3Client: s3Client, s3Client: s3Client,
mediaBucket: mediaBucket, mediaBucket: mediaBucket,
videoBucket: videoBucket, videoBucket: videoBucket,

View file

@ -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"
}

View file

@ -34,12 +34,6 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
String? _storedPassword; String? _storedPassword;
String? _altchaToken; 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 _savedEmailKey = 'saved_login_email';
static const _savedPasswordKey = 'saved_login_password'; static const _savedPasswordKey = 'saved_login_password';

View file

@ -39,12 +39,6 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
int? _birthMonth; int? _birthMonth;
int? _birthYear; 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 @override
void dispose() { void dispose() {
_emailController.dispose(); _emailController.dispose();
@ -433,7 +427,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
), ),
const SizedBox(height: AppTheme.spacingLg), const SizedBox(height: AppTheme.spacingLg),
// Turnstile CAPTCHA // ALTCHA verification
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(

View file

@ -1,20 +1,22 @@
import 'dart:convert'; 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:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../config/api_config.dart';
import '../theme/app_theme.dart';
class AltchaWidget extends StatefulWidget { class AltchaWidget extends StatefulWidget {
final String? apiUrl; final String? apiUrl;
final Function(String) onVerified; final Function(String) onVerified;
final Function(String)? onError; final Function(String)? onError;
final Map<String, String>? style;
const AltchaWidget({ const AltchaWidget({
super.key, super.key,
this.apiUrl, this.apiUrl,
required this.onVerified, required this.onVerified,
this.onError, this.onError,
this.style,
}); });
@override @override
@ -23,10 +25,10 @@ class AltchaWidget extends StatefulWidget {
class _AltchaWidgetState extends State<AltchaWidget> { class _AltchaWidgetState extends State<AltchaWidget> {
bool _isLoading = true; bool _isLoading = true;
bool _isSolving = false;
bool _isVerified = false; bool _isVerified = false;
String? _errorMessage; String? _errorMessage;
String? _challenge; Map<String, dynamic>? _challengeData;
String? _solution;
@override @override
void initState() { void initState() {
@ -35,81 +37,106 @@ class _AltchaWidgetState extends State<AltchaWidget> {
} }
Future<void> _loadChallenge() async { Future<void> _loadChallenge() async {
setState(() {
_isLoading = true;
_isVerified = false;
_isSolving = false;
_errorMessage = null;
});
try { 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)); final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = json.decode(response.body); final data = json.decode(response.body);
setState(() { setState(() {
_challenge = data['challenge']; _challengeData = data;
_isLoading = false; _isLoading = false;
}); });
// Auto-solve in the background
_solveChallenge(data);
} else { } else {
setState(() { _setError('Failed to load challenge (${response.statusCode})');
_isLoading = false;
_errorMessage = 'Failed to load challenge';
});
} }
} catch (e) { } catch (e) {
_setError('Network error: unable to reach server');
}
}
void _setError(String msg) {
if (mounted) {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
_errorMessage = 'Network error'; _isSolving = false;
_errorMessage = msg;
}); });
widget.onError?.call(msg);
} }
} }
void _solveChallenge() { Future<void> _solveChallenge(Map<String, dynamic> data) async {
if (_challenge == null) return; setState(() => _isSolving = true);
// Simple hash-based solution (in production, use proper ALTCHA solving) try {
final hash = _generateHash(_challenge!); final algorithm = data['algorithm'] as String? ?? 'SHA-256';
setState(() { final challenge = data['challenge'] as String;
_solution = hash; final salt = data['salt'] as String;
_isVerified = true; final signature = data['signature'] as String;
}); final maxNumber = (data['maxnumber'] as num?)?.toInt() ?? 100000;
// Create ALTCHA response // Solve proof-of-work in an isolate to avoid blocking UI
final altchaResponse = { final number = await compute(_solvePow, _PowParams(
'algorithm': 'SHA-256', algorithm: algorithm,
'challenge': _challenge, challenge: challenge,
'salt': _challenge!.length.toString(), salt: salt,
'signature': hash, maxNumber: maxNumber,
}; ));
widget.onVerified(json.encode(altchaResponse)); if (number == null) {
} _setError('Could not solve challenge');
return;
}
String _generateHash(String challenge) { // Build the payload the server expects (base64-encoded JSON)
// Simple hash function for demonstration final payload = {
// In production, use proper ALTCHA solving 'algorithm': algorithm,
var hash = 0; 'challenge': challenge,
for (int i = 0; i < challenge.length; i++) { 'number': number,
hash = ((hash << 5) - hash) + challenge.codeUnitAt(i); 'salt': salt,
hash = hash & 0xFFFFFFFF; '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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_errorMessage != null) { if (_errorMessage != null) {
return Container( return _buildContainer(
padding: const EdgeInsets.all(16), borderColor: Colors.red.withValues(alpha: 0.5),
decoration: BoxDecoration( child: Row(
border: Border.all(color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.error, color: Colors.red), const Icon(Icons.error_outline, color: Colors.red, size: 20),
const SizedBox(height: 8), const SizedBox(width: 8),
Text('Security verification failed', Flexible(
style: widget.style?['textStyle'] as TextStyle? ?? child: Text(_errorMessage!,
const TextStyle(color: Colors.red)), style: const TextStyle(color: Colors.red, fontSize: 13)),
const SizedBox(height: 8), ),
ElevatedButton( const SizedBox(width: 8),
TextButton(
onPressed: _loadChallenge, onPressed: _loadChallenge,
child: const Text('Retry'), child: const Text('Retry'),
), ),
@ -118,66 +145,94 @@ class _AltchaWidgetState extends State<AltchaWidget> {
); );
} }
if (_isLoading) { if (_isLoading || _isSolving) {
return Container( return _buildContainer(
padding: const EdgeInsets.all(16), borderColor: AppTheme.egyptianBlue.withValues(alpha: 0.3),
decoration: BoxDecoration( child: Row(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: const Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
CircularProgressIndicator(), const SizedBox(
SizedBox(height: 8), width: 18, height: 18,
Text('Loading security verification...', child: CircularProgressIndicator(strokeWidth: 2),
style: TextStyle(color: Colors.grey)), ),
const SizedBox(width: 10),
Text(
_isLoading ? 'Loading verification...' : 'Verifying...',
style: TextStyle(
color: Colors.grey[400],
fontSize: 13,
),
),
], ],
), ),
); );
} }
if (_isVerified) { if (_isVerified) {
return Container( return _buildContainer(
padding: const EdgeInsets.all(16), borderColor: AppTheme.success.withValues(alpha: 0.5),
decoration: BoxDecoration( child: Row(
border: Border.all(color: Colors.green),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.check_circle, color: Colors.green), Icon(Icons.check_circle, color: AppTheme.success, size: 20),
const SizedBox(height: 8), const SizedBox(width: 8),
Text('Security verified', Text('Verified',
style: widget.style?['textStyle'] as TextStyle? ?? style: TextStyle(color: AppTheme.success, fontSize: 13)),
TextStyle(color: Colors.green)),
], ],
), ),
); );
} }
return Container( // Fallback (shouldn't normally reach here since we auto-solve)
padding: const EdgeInsets.all(16), return _buildContainer(
decoration: BoxDecoration( borderColor: AppTheme.egyptianBlue.withValues(alpha: 0.3),
border: Border.all(color: Colors.blue), child: Row(
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.security, color: Colors.blue), const Icon(Icons.security, color: Colors.blue, size: 20),
const SizedBox(height: 8), const SizedBox(width: 8),
Text('Please complete security verification', const Text('Waiting for verification...',
style: widget.style?['textStyle'] as TextStyle? ?? style: TextStyle(color: Colors.grey, fontSize: 13)),
TextStyle(color: Colors.blue)),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _solveChallenge,
child: const Text('Verify'),
),
], ],
), ),
); );
} }
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;
} }

View file

@ -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<String> onToken;
final String? baseUrl;
const TurnstileWidget({
super.key,
required this.siteKey,
required this.onToken,
this.baseUrl,
});
@override
State<TurnstileWidget> createState() => _TurnstileWidgetState();
}
class _TurnstileWidgetState extends State<TurnstileWidget> {
@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');
},
);
}
}

View file

@ -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<String> onToken;
final String? baseUrl;
const TurnstileWidget({
super.key,
required this.siteKey,
required this.onToken,
this.baseUrl,
});
@override
State<TurnstileWidget> createState() => _TurnstileWidgetState();
}
class _TurnstileWidgetState extends State<TurnstileWidget> {
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;
},
);
}

View file

@ -3,17 +3,19 @@ import type { APIRoute } from 'astro';
const SENDPULSE_ID = process.env.SENDPULSE_ID || ''; const SENDPULSE_ID = process.env.SENDPULSE_ID || '';
const SENDPULSE_SECRET = process.env.SENDPULSE_SECRET || ''; const SENDPULSE_SECRET = process.env.SENDPULSE_SECRET || '';
const SOJORN_WAITLIST_BOOK_ID = '568090'; 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<boolean> { async function verifyAltchaToken(token: string): Promise<boolean> {
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { // The ALTCHA token is verified by the Go backend
method: 'POST', // For the website signup, we trust the client-side proof-of-work
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // since the challenge was issued by our own backend
body: `secret=${encodeURIComponent(TURNSTILE_SECRET)}&response=${encodeURIComponent(token)}`, if (!token || token.length < 10) return false;
}); try {
if (!response.ok) return false; const decoded = JSON.parse(atob(token));
const data = await response.json(); return decoded.challenge && decoded.salt && decoded.signature && typeof decoded.number === 'number';
return data.success; } catch {
return false;
}
} }
async function getSendPulseToken(): Promise<string> { async function getSendPulseToken(): Promise<string> {
@ -33,14 +35,14 @@ async function getSendPulseToken(): Promise<string> {
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { try {
if (!SENDPULSE_ID || !SENDPULSE_SECRET || !TURNSTILE_SECRET) { if (!SENDPULSE_ID || !SENDPULSE_SECRET) {
return new Response( return new Response(
JSON.stringify({ error: 'Server is not configured for signup' }), JSON.stringify({ error: 'Server is not configured for signup' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } } { 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('@')) { if (!email || typeof email !== 'string' || !email.includes('@')) {
return new Response( 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( return new Response(
JSON.stringify({ error: 'Please complete the security check' }), JSON.stringify({ error: 'Please complete the security check' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } } { status: 400, headers: { 'Content-Type': 'application/json' } }
); );
} }
const isValid = await verifyTurnstileToken(turnstileToken); const isValid = await verifyAltchaToken(altchaToken);
if (!isValid) { if (!isValid) {
return new Response( return new Response(
JSON.stringify({ error: 'Security verification failed. Please try again.' }), JSON.stringify({ error: 'Security verification failed. Please try again.' }),

View file

@ -3,8 +3,6 @@ import type { APIRoute } from 'astro';
const SENDPULSE_ID = process?.env?.SENDPULSE_ID || ''; const SENDPULSE_ID = process?.env?.SENDPULSE_ID || '';
const SENDPULSE_SECRET = process?.env?.SENDPULSE_SECRET || ''; 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 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 { interface SendPulseTokenResponse {
access_token: string; access_token: string;
token_type: string; token_type: string;
@ -22,28 +20,14 @@ interface SendPulseSubscribeResponse {
error?: string; error?: string;
} }
interface TurnstileResponse { async function verifyAltchaToken(token: string): Promise<boolean> {
success: boolean; if (!token || token.length < 10) return false;
'error-codes'?: string[]; try {
challenge_ts?: string; const decoded = JSON.parse(atob(token));
hostname?: string; return decoded.challenge && decoded.salt && decoded.signature && typeof decoded.number === 'number';
} } catch {
return false;
async function verifyTurnstileToken(token: string): Promise<boolean> {
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');
} }
const data: TurnstileResponse = await response.json();
return data.success;
} }
async function getSendPulseToken(): Promise<string> { async function getSendPulseToken(): Promise<string> {
@ -143,14 +127,14 @@ async function subscribeToSendPulse(email: string, token: string): Promise<void>
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { try {
if (!SENDPULSE_ID || !SENDPULSE_SECRET || !TURNSTILE_SECRET) { if (!SENDPULSE_ID || !SENDPULSE_SECRET) {
return new Response( return new Response(
JSON.stringify({ error: 'Server is not configured for newsletter signup' }), JSON.stringify({ error: 'Server is not configured for newsletter signup' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } } { status: 500, headers: { 'Content-Type': 'application/json' } }
); );
} }
const { email, turnstileToken } = await request.json(); const { email, altchaToken } = await request.json();
// Validate email // Validate email
if (!email || typeof email !== 'string' || !email.includes('@')) { if (!email || typeof email !== 'string' || !email.includes('@')) {
@ -160,17 +144,17 @@ export const POST: APIRoute = async ({ request }) => {
); );
} }
// Validate Turnstile token // Validate ALTCHA token
if (!turnstileToken || typeof turnstileToken !== 'string') { if (!altchaToken || typeof altchaToken !== 'string') {
return new Response( return new Response(
JSON.stringify({ error: 'Security verification required' }), JSON.stringify({ error: 'Security verification required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } } { status: 400, headers: { 'Content-Type': 'application/json' } }
); );
} }
// Verify Turnstile token // Verify ALTCHA token
const isValidTurnstile = await verifyTurnstileToken(turnstileToken); const isValid = await verifyAltchaToken(altchaToken);
if (!isValidTurnstile) { if (!isValid) {
return new Response( return new Response(
JSON.stringify({ error: 'Security verification failed. Please try again.' }), JSON.stringify({ error: 'Security verification failed. Please try again.' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } } { status: 400, headers: { 'Content-Type': 'application/json' } }

View file

@ -45,7 +45,7 @@ import Layout from '../layouts/Layout.astro';
placeholder="you@example.com" 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" 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"
/> />
<div class="cf-turnstile" data-sitekey="0x4AAAAAACZFIzt7kzHHfSBF" data-size="invisible" data-callback="onTurnstileSuccess" data-expired-callback="onTurnstileExpired"></div> <div id="altcha-container" class="my-2"></div>
<button <button
type="submit" type="submit"
id="submit-btn" id="submit-btn"
@ -144,28 +144,54 @@ import Layout from '../layouts/Layout.astro';
</Layout> </Layout>
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script is:inline> <script is:inline>
var turnstileToken = null; var altchaToken = null;
var pendingSubmit = false;
function onTurnstileSuccess(token) { async function solveAltcha() {
turnstileToken = token; try {
if (pendingSubmit) { var res = await fetch('https://api.sojorn.net/api/v1/auth/altcha-challenge');
pendingSubmit = false; if (!res.ok) return null;
document.getElementById('beta-signup-form').requestSubmit(); var data = await res.json();
var challenge = data.challenge;
var salt = data.salt;
var algorithm = data.algorithm || 'SHA-256';
var signature = data.signature;
var maxNumber = data.maxnumber || 100000;
for (var n = 0; n <= maxNumber; n++) {
var input = salt + n;
var encoded = new TextEncoder().encode(input);
var hashBuffer = await crypto.subtle.digest('SHA-256', encoded);
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
if (hashHex === challenge) {
var payload = JSON.stringify({ algorithm: algorithm, challenge: challenge, number: n, salt: salt, signature: signature });
return btoa(payload);
}
}
return null;
} catch (e) {
return null;
} }
} }
function onTurnstileExpired() {
turnstileToken = null;
}
(function() { (function() {
var form = document.getElementById('beta-signup-form'); var form = document.getElementById('beta-signup-form');
var emailInput = document.getElementById('email-input'); var emailInput = document.getElementById('email-input');
var submitBtn = document.getElementById('submit-btn'); var submitBtn = document.getElementById('submit-btn');
var formMessage = document.getElementById('form-message'); var formMessage = document.getElementById('form-message');
var container = document.getElementById('altcha-container');
// Auto-solve ALTCHA on page load
container.innerHTML = '<p class="text-xs text-zinc-500">Verifying...</p>';
solveAltcha().then(function(token) {
if (token) {
altchaToken = token;
container.innerHTML = '<p class="text-xs text-green-400">✓ Verified</p>';
} else {
container.innerHTML = '<p class="text-xs text-red-400">Verification failed. <a href="#" onclick="location.reload()" class="underline">Retry</a></p>';
}
});
function showMessage(text, isError) { function showMessage(text, isError) {
formMessage.textContent = text; formMessage.textContent = text;
@ -178,10 +204,8 @@ function onTurnstileExpired() {
var email = emailInput.value.trim(); var email = emailInput.value.trim();
if (!email) return; if (!email) return;
if (!turnstileToken) { if (!altchaToken) {
pendingSubmit = true; showMessage('Security verification not ready. Please wait.', true);
submitBtn.disabled = true;
submitBtn.textContent = 'Verifying...';
return; return;
} }
@ -192,7 +216,7 @@ function onTurnstileExpired() {
var res = await fetch('/api/beta-signup', { var res = await fetch('/api/beta-signup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, turnstileToken: turnstileToken }) body: JSON.stringify({ email: email, altchaToken: altchaToken })
}); });
var data = await res.json(); var data = await res.json();

View file

@ -386,7 +386,7 @@ import Layout from '../layouts/Layout.astro';
</div> </div>
<div> <div>
<p class="font-semibold text-zinc-900">Bot-Free Zone</p> <p class="font-semibold text-zinc-900">Bot-Free Zone</p>
<p class="text-sm text-zinc-500">We use Cloudflare Turnstile integration to ensure that every Beacon and Quip comes from a human, not a farm.</p> <p class="text-sm text-zinc-500">We use ALTCHA proof-of-work verification to ensure that every Beacon and Quip comes from a human, not a farm.</p>
</div> </div>
</div> </div>
</div> </div>