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-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)

View file

@ -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", ""),

View file

@ -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,

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? _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';

View file

@ -39,12 +39,6 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
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<SignUpScreen> {
),
const SizedBox(height: AppTheme.spacingLg),
// Turnstile CAPTCHA
// ALTCHA verification
Container(
decoration: BoxDecoration(
border: Border.all(

View file

@ -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<String, String>? 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<AltchaWidget> {
bool _isLoading = true;
bool _isSolving = false;
bool _isVerified = false;
String? _errorMessage;
String? _challenge;
String? _solution;
Map<String, dynamic>? _challengeData;
@override
void initState() {
@ -35,81 +37,106 @@ class _AltchaWidgetState extends State<AltchaWidget> {
}
Future<void> _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;
Future<void> _solveChallenge(Map<String, dynamic> data) async {
setState(() => _isSolving = true);
// Simple hash-based solution (in production, use proper ALTCHA solving)
final hash = _generateHash(_challenge!);
setState(() {
_solution = hash;
_isVerified = true;
});
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;
// Create ALTCHA response
final altchaResponse = {
'algorithm': 'SHA-256',
'challenge': _challenge,
'salt': _challenge!.length.toString(),
'signature': hash,
// 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,
};
widget.onVerified(json.encode(altchaResponse));
}
final token = base64Encode(utf8.encode(json.encode(payload)));
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;
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<AltchaWidget> {
);
}
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;
}

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_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<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) return false;
const data = await response.json();
return data.success;
async function verifyAltchaToken(token: string): Promise<boolean> {
// 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<string> {
@ -33,14 +35,14 @@ async function getSendPulseToken(): Promise<string> {
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.' }),

View file

@ -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<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');
async function verifyAltchaToken(token: string): Promise<boolean> {
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<string> {
@ -143,14 +127,14 @@ async function subscribeToSendPulse(email: string, token: string): Promise<void>
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' } }

View file

@ -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"
/>
<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
type="submit"
id="submit-btn"
@ -144,21 +144,35 @@ import Layout from '../layouts/Layout.astro';
</Layout>
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script is:inline>
var turnstileToken = null;
var pendingSubmit = false;
var altchaToken = null;
function onTurnstileSuccess(token) {
turnstileToken = token;
if (pendingSubmit) {
pendingSubmit = false;
document.getElementById('beta-signup-form').requestSubmit();
async function solveAltcha() {
try {
var res = await fetch('https://api.sojorn.net/api/v1/auth/altcha-challenge');
if (!res.ok) return null;
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() {
@ -166,6 +180,18 @@ function onTurnstileExpired() {
var emailInput = document.getElementById('email-input');
var submitBtn = document.getElementById('submit-btn');
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) {
formMessage.textContent = text;
@ -178,10 +204,8 @@ function onTurnstileExpired() {
var email = emailInput.value.trim();
if (!email) return;
if (!turnstileToken) {
pendingSubmit = true;
submitBtn.disabled = true;
submitBtn.textContent = 'Verifying...';
if (!altchaToken) {
showMessage('Security verification not ready. Please wait.', true);
return;
}
@ -192,7 +216,7 @@ function onTurnstileExpired() {
var res = await fetch('/api/beta-signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, turnstileToken: turnstileToken })
body: JSON.stringify({ email: email, altchaToken: altchaToken })
});
var data = await res.json();

View file

@ -386,7 +386,7 @@ import Layout from '../layouts/Layout.astro';
</div>
<div>
<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>