feat: replace Turnstile with ALTCHA across Flutter app, Go backend, and website
This commit is contained in:
parent
602a139349
commit
2bfb8eecea
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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", ""),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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.' }),
|
||||||
|
|
|
||||||
|
|
@ -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' } }
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue