feat: add Turnstile to login, improve email templates, and security cleanup

- Add Cloudflare Turnstile verification to login flow
- Add API_BASE_URL and APP_BASE_URL to config for environment flexibility
- Redesign verification and password reset emails with modern HTML templates
- Use config URLs instead of hardcoded domains in auth handlers
- Remove sensitive logging from OTK operations for security
- Delete unused deployment and draft inspection scripts
- Add TURNSTILE_SITE_KEY to Flutter run
This commit is contained in:
Patrick Britton 2026-02-06 08:51:34 -06:00
parent c9d8e0c7e6
commit 0954c1e2a3
29 changed files with 842 additions and 284 deletions

View file

@ -1,68 +0,0 @@
# Deploy all Edge Functions to Supabase
# Run this after updating supabase-js version
Write-Host "=== Deploying All Edge Functions ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "This will deploy all functions with --no-verify-jwt (default for this script)" -ForegroundColor Yellow
Write-Host ""
$functions = @(
"appreciate",
"block",
"calculate-harmony",
"cleanup-expired-content",
"consume_one_time_prekey",
"create-beacon",
"deactivate-account",
"delete-account",
"e2ee_session_manager",
"feed-personal",
"feed-sojorn",
"follow",
"manage-post",
"notifications",
"profile",
"profile-posts",
"public-config",
"publish-comment",
"publish-post",
"push-notification",
"report",
"save",
"search",
"sign-media",
"signup",
"tone-check",
"trending",
"upload-image"
)
$totalFunctions = $functions.Count
$currentFunction = 0
$noVerifyJwt = "--no-verify-jwt"
foreach ($func in $functions) {
$currentFunction++
Write-Host "[$currentFunction/$totalFunctions] Deploying $func..." -ForegroundColor Yellow
try {
supabase functions deploy $func $noVerifyJwt 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host " OK $func deployed successfully" -ForegroundColor Green
} else {
Write-Host " FAILED to deploy $func" -ForegroundColor Red
}
}
catch {
Write-Host " ERROR deploying $func : $_" -ForegroundColor Red
}
}
Write-Host ""
Write-Host "=== Deployment Complete ===" -ForegroundColor Cyan
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host "1. Restart your Flutter app" -ForegroundColor Yellow
Write-Host "2. Sign in again" -ForegroundColor Yellow
Write-Host "3. The JWT 401 errors should be gone!" -ForegroundColor Green
Write-Host ""

View file

@ -1,23 +0,0 @@
package main
import (
"os"
"github.com/joho/godotenv"
)
func main() {
// Load .env manualy since config might rely on it
godotenv.Load()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
// Fallback for local dev if .env not loaded or empty
dbURL = "postgresql://postgres:password@localhost:5432/sojorn_db" // Guessing name?
// Wait, user used 'postgres' db in psql command attempts.
// Let's assume standard postgres connection string.
// The psql command failed so I don't know the DB Name.
// But main.go loads config. Let's rely on that if possible, but importing main's config might be circular or complex if not in a lib.
// I'll try to use the one from config.LoadConfig() by importing github.com/patbritton/sojorn-backend/internal/config
}
}

View file

@ -36,6 +36,8 @@ type Config struct {
R2MediaBucket string
R2VideoBucket string
TurnstileSecretKey string
APIBaseURL string
AppBaseURL string
}
func LoadConfig() *Config {
@ -78,6 +80,8 @@ func LoadConfig() *Config {
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://sojorn.net"),
}
}

View file

@ -45,8 +45,9 @@ type RegisterRequest struct {
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
TurnstileToken string `json:"turnstile_token" binding:"required"`
}
func (h *AuthHandler) Register(c *gin.Context) {
@ -160,6 +161,23 @@ func (h *AuthHandler) Login(c *gin.Context) {
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Validate Turnstile token
turnstileService := services.NewTurnstileService(h.config.TurnstileSecretKey)
remoteIP := c.ClientIP()
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)
if err != nil {
log.Printf("[Auth] Login Turnstile verification failed: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
return
}
if !turnstileResp.Success {
errorMsg := turnstileService.GetErrorMessage(turnstileResp.ErrorCodes)
log.Printf("[Auth] Login Turnstile validation failed: %s", errorMsg)
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
return
}
user, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email)
if err != nil {
log.Printf("[Auth] Login failed for %s: user not found", req.Email)
@ -234,7 +252,7 @@ func (h *AuthHandler) CompleteOnboarding(c *gin.Context) {
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
rawToken := c.Query("token")
if rawToken == "" {
c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=invalid_token")
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid_token")
return
}
@ -243,19 +261,19 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString)
if err != nil {
c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=invalid_token")
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid_token")
return
}
if time.Now().After(expiresAt) {
h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=expired")
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=expired")
return
}
// Activate user
if err := h.repo.UpdateUserStatus(c.Request.Context(), userID, models.UserStatusActive); err != nil {
c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=server_error")
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=server_error")
return
}
@ -275,7 +293,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
// Cleanup
_ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
c.Redirect(http.StatusFound, "https://sojorn.net/verified")
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verified?status=success")
}
func (h *AuthHandler) ResendVerificationEmail(c *gin.Context) {

View file

@ -559,7 +559,7 @@ func (r *UserRepository) DeleteUsedOTK(ctx context.Context, userID string, keyID
if err != nil {
return fmt.Errorf("failed to delete used OTK: %w", err)
}
fmt.Printf("[KEYS] Deleted used OTK #%d for user %s\n", keyID, userID)
// OTK deleted successfully
return nil
}
@ -613,9 +613,7 @@ func (r *UserRepository) GetSignalKeyBundle(ctx context.Context, userID string)
"key_id": otkID,
"public_key": otkPub,
}
fmt.Printf("[KEYS] Retrieved OTK #%d for user %s\n", otkID, userID)
} else {
fmt.Printf("[KEYS] No OTKs available for user %s\n", userID)
// OTK retrieved - not logging user ID for security
}
// Handle NULL values properly

View file

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
@ -51,65 +52,55 @@ type sendPulseIdentity struct {
func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error {
subject := "Verify your Sojorn account"
verifyURL := fmt.Sprintf("https://api.sojorn.net/api/v1/auth/verify?token=%s", token)
// Ensure we don't double up on /api/v1 if it's already in the config
apiBase := strings.TrimSuffix(s.config.APIBaseURL, "/api/v1")
verifyURL := fmt.Sprintf("%s/api/v1/auth/verify?token=%s", apiBase, token)
body := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: #f4f4f5; }
.wrapper { padding: 40px 20px; }
.container { max-width: 500px; margin: 0 auto; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
.header { background: #09090b; padding: 30px; text-align: center; }
.content { padding: 40px; text-align: center; color: #3f3f46; line-height: 1.6; }
.logo { width: 120px; height: auto; margin-bottom: 10px; }
h1 { color: #18181b; font-size: 24px; font-weight: 700; margin-bottom: 16px; }
p { margin-bottom: 24px; font-size: 16px; }
.button { display: inline-block; padding: 14px 32px; background-color: #10B981; color: #ffffff !important; text-decoration: none; border-radius: 12px; font-weight: 600; font-size: 16px; transition: background-color 0.2s; }
.footer { padding: 24px; text-align: center; font-size: 14px; color: #71717a; border-top: 1px solid #f4f4f5; }
.link { color: #10B981; text-decoration: none; word-break: break-all; font-size: 12px; }
</style>
</head>
<body>
<div class="wrapper">
<div class="container">
<div class="header">
<img src="https://sojorn.net/web.png" alt="Sojorn" class="logo">
</div>
<div class="content">
<h1>Welcome to Sojorn, %s</h1>
<p>Thanks for signing up! To get started, please verify your email address by clicking the button below.</p>
<a href="%s" class="button">Verify Email Address</a>
<p style="margin-top: 32px; font-size: 14px; color: #a1a1aa;">If the button doesn't work, copy and paste this link into your browser:</p>
<a href="%s" class="link">%s</a>
</div>
<div class="footer">
<p>If you didn't create an account, you can safely ignore this email.</p>
<p>&copy; 2026 Sojorn. All rights reserved.</p>
</div>
</div>
</div>
</body>
</html>
`, toName, verifyURL, verifyURL, verifyURL)
title := "Email Verification"
header := fmt.Sprintf("Hey %s! 👋", toName)
if toName == "" {
header = "Hey there! 👋"
}
return s.sendEmail(toEmail, toName, subject, body, "Verify your Sojorn account: "+verifyURL)
content := `
<p>Welcome to Sojorn your vibrant new social space. We're thrilled to have you join our community!</p>
<p>To get started in the app, please verify your email address by clicking the button below:</p>
`
footer := `
<div style="background: #F9FAFB; border-radius: 12px; padding: 20px; margin-top: 24px; text-align: left;">
<p style="font-size: 13px; color: #9CA3AF; margin-bottom: 8px;">If the button doesn't work, copy and paste this link into your browser:</p>
<a href="%s" style="color: #4338CA; text-decoration: underline; word-break: break-all; font-size: 12px; font-weight: 500;">%s</a>
</div>
`
footer = fmt.Sprintf(footer, verifyURL, verifyURL)
htmlBody := s.buildHTMLEmail(title, header, content, verifyURL, "Verify My Email", footer)
textBody := fmt.Sprintf("Welcome to Sojorn! Please verify your email by clicking here: %s", verifyURL)
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
}
func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error {
subject := "Reset your Sojorn password"
resetURL := fmt.Sprintf("https://sojorn.net/reset-password?token=%s", token)
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.AppBaseURL, token)
body := fmt.Sprintf(`
<h2>Reset Password for %s</h2>
<p>You requested a password reset. Click the link below to set a new password:</p>
<p><a href="%s" style="padding: 10px 20px; background-color: #dc3545; color: white; text-decoration: none; border-radius: 5px;">Reset Password</a></p>
<p>This link expires in 1 hour.</p>
<p>If you did not request this, please ignore this email.</p>
`, toName, resetURL)
title := "Password Reset"
header := "Reset your password"
content := fmt.Sprintf(`
<p>Hey %s,</p>
<p>You requested a password reset for your Sojorn account. Click the button below to set a new password:</p>
`, toName)
return s.sendEmail(toEmail, toName, subject, body, "Reset your password: "+resetURL)
footer := `
<p style="color: #9CA3AF; font-size: 13px; margin-top: 24px;">This link expires in 1 hour. If you did not request this, you can safely ignore this email.</p>
`
htmlBody := s.buildHTMLEmail(title, header, content, resetURL, "Reset Password", footer)
textBody := fmt.Sprintf("Reset your Sojorn password: %s", resetURL)
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
}
func (s *EmailService) sendEmail(toEmail, toName, subject, htmlBody, textBody string) error {
@ -137,7 +128,15 @@ func (s *EmailService) sendEmail(toEmail, toName, subject, htmlBody, textBody st
s.config.SMTPPort,
)
err := emailSender.SendPlainEmail([]string{toEmail}, nil, subject, htmlBody, nil)
// SMTP Fallback - Send HTML email
err := emailSender.SendHTMLEmail(
"Sojorn", // from name
[]string{toEmail}, // recipients
nil, // cc
subject, // subject
htmlBody, // html body
nil, // attachments
)
if err != nil {
log.Error().Err(err).Msg("Failed to send email via SMTP")
return err
@ -256,3 +255,51 @@ func (s *EmailService) AddSubscriber(email, name string) {
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
// Endpoint: POST /addressbooks/{id}/emails
}
func (s *EmailService) buildHTMLEmail(title, header, content, buttonURL, buttonText, footer string) string {
return fmt.Sprintf(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: #F3F4F6;">
<div style="padding: 40px 20px; background-color: #F3F4F6;">
<div style="max-width: 520px; margin: 0 auto; background-color: #ffffff; border-radius: 24px; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #4338CA 0%%, #6366F1 100%%); padding: 40px; text-align: center;">
<img src="https://sojorn.net/web.png" alt="Sojorn" style="width: 80px; height: 80px; border-radius: 20px; margin-bottom: 16px;">
<div style="color: #ffffff; font-size: 12px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; opacity: 0.9;">%s</div>
</div>
<!-- Content -->
<div style="padding: 40px; text-align: center; color: #374151;">
<h1 style="color: #1F2937; font-size: 24px; font-weight: 700; margin-bottom: 16px;">%s</h1>
<div style="font-size: 16px; line-height: 1.6; color: #4B5563; margin-bottom: 32px;">
%s
</div>
<a href="%s" style="display: inline-block; padding: 16px 40px; background-color: #4338CA; color: #ffffff; text-decoration: none; border-radius: 12px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
%s
</a>
%s
</div>
<!-- Footer -->
<div style="padding: 32px; text-align: center; background-color: #F9FAFB; border-top: 1px solid #E5E7EB;">
<p style="font-size: 12px; color: #9CA3AF; margin-bottom: 8px;">© 2026 Sojorn by MPLS LLC. All rights reserved.</p>
<div style="font-size: 12px; color: #9CA3AF;">
<a href="https://sojorn.net" style="color: #9CA3AF; text-decoration: none; margin: 0 8px;">Website</a>
<a href="https://sojorn.net/privacy" style="color: #9CA3AF; text-decoration: none; margin: 0 8px;">Privacy</a>
<a href="https://sojorn.net/terms" style="color: #9CA3AF; text-decoration: none; margin: 0 8px;">Terms</a>
</div>
</div>
</div>
</div>
</body>
</html>
`, title, title, header, content, buttonURL, buttonText, footer)
}

View file

@ -42,10 +42,7 @@ $defineArgs = @(
$optionalDefines = @(
'FIREBASE_WEB_VAPID_KEY',
'SUPABASE_PUBLISHABLE_KEY',
'SUPABASE_SECRET_KEY',
'SUPABASE_JWT_KID',
'SUPABASE_JWKS_URI'
'TURNSTILE_SITE_KEY'
)
foreach ($opt in $optionalDefines) {
@ -57,6 +54,7 @@ foreach ($opt in $optionalDefines) {
Push-Location (Join-Path $PSScriptRoot "sojorn_app")
try {
flutter run @defineArgs @Args
} finally {
}
finally {
Pop-Location
}

View file

@ -32,7 +32,7 @@ $values = Parse-Env $EnvPath
# Collect dart-defines we actually use on web.
$defineArgs = @()
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
foreach ($k in $keysOfInterest) {
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
$defineArgs += "--dart-define=$k=$($values[$k])"

View file

@ -32,7 +32,7 @@ $values = Parse-Env $EnvPath
# Collect dart-defines we actually use on web.
$defineArgs = @()
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
foreach ($k in $keysOfInterest) {
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
$defineArgs += "--dart-define=$k=$($values[$k])"

View file

@ -31,7 +31,7 @@ $values = Parse-Env $EnvPath
# Collect dart-defines we actually use on Windows.
$defineArgs = @()
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
foreach ($k in $keysOfInterest) {
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
$defineArgs += "--dart-define=$k=$($values[$k])"

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -251,8 +251,11 @@ class Post {
videoUrl: json['video_url'] as String?,
thumbnailUrl: json['thumbnail_url'] as String?,
durationMs: _parseInt(json['duration_ms']),
hasVideoContent: json['has_video_content'] as bool?,
hasVideoContent: json['has_video_content'] as bool? ??
((json['video_url'] as String?)?.isNotEmpty == true ||
(json['image_url'] as String?)?.toLowerCase().endsWith('.mp4') == true),
bodyFormat: json['body_format'] as String?,
backgroundId: json['background_id'] as String?,
tags: _parseTags(json['tags']),
reactions: _parseReactions(

View file

@ -87,9 +87,11 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
videoFile,
onProgress: (p) => state = state.copyWith(progress: 0.1 + (p * 0.4)),
);
print('Video uploaded successfully: $videoUrl');
state = state.copyWith(progress: 0.5);
// Upload thumbnail to Go Backend / R2
String? thumbnailUrl;
try {

View file

@ -100,8 +100,11 @@ class AppRoutes {
routes: [
GoRoute(
path: quips,
builder: (_, __) => const QuipsFeedScreen(),
builder: (_, state) => QuipsFeedScreen(
initialPostId: state.uri.queryParameters['postId'],
),
),
],
),
StatefulShellBranch(
@ -195,7 +198,16 @@ class AppRoutes {
return '$baseUrl/u/$username';
}
/// Get shareable URL for a post (future implementation)
/// Get shareable URL for a quip
/// Returns: https://sojorn.net/quips?postId=postid
static String getQuipUrl(
String postId, {
String baseUrl = 'https://sojorn.net',
}) {
return '$baseUrl/quips?postId=$postId';
}
/// Get shareable URL for a post
/// Returns: https://sojorn.net/p/postid
static String getPostUrl(
String postId, {
@ -204,6 +216,7 @@ class AppRoutes {
return '$baseUrl/p/$postId';
}
/// Get shareable URL for a beacon location
/// Returns: https://sojorn.net/beacon?lat=...&long=...
static String getBeaconUrl(

View file

@ -31,7 +31,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
bool _saveCredentials = true;
String? _storedEmail;
String? _storedPassword;
String? _captchaToken;
String? _turnstileToken;
// 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';
@ -94,14 +100,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
bool get _canUseBiometricLogin =>
_supportsBiometric &&
_hasStoredCredentials &&
!_isBiometricAuthenticating;
!_isBiometricAuthenticating &&
_turnstileToken != null; // Require Turnstile for biometric too
Future<void> _signIn() async {
final email = _emailController.text.trim();
final password = _passwordController.text;
print('[SignIn] Attempting sign-in for $email');
if (email.isEmpty || !email.contains('@')) {
setState(() {
_errorMessage = 'Please enter a valid email address';
@ -116,6 +121,14 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
return;
}
// Validate Turnstile token
if (_turnstileToken == null || _turnstileToken!.isEmpty) {
setState(() {
_errorMessage = 'Please complete the security verification';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
@ -123,18 +136,18 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
try {
final authService = ref.read(authServiceProvider);
print('[SignIn] Calling signInWithGoBackend...');
await authService.signInWithGoBackend(
email: email,
password: password,
turnstileToken: _turnstileToken!,
);
print('[SignIn] Sign-in successful!');
await _persistCredentials(email, password);
} catch (e) {
print('[SignIn] Error: $e');
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
// Reset Turnstile token on error so user must re-verify
_turnstileToken = null;
});
}
} finally {
@ -382,7 +395,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
obscureText: true,
textInputAction: TextInputAction.done,
prefixIcon: Icons.lock_outline,
onEditingComplete: _signIn,
onEditingComplete: _turnstileToken != null ? _signIn : null,
autofillHints: const [AutofillHints.password],
onChanged: (_) {
if (_errorMessage != null) {
@ -393,6 +406,46 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
},
),
const SizedBox(height: AppTheme.spacingLg),
// Turnstile CAPTCHA
Container(
decoration: BoxDecoration(
border: Border.all(
color: _turnstileToken != null
? AppTheme.success
: AppTheme.egyptianBlue.withOpacity(0.3),
width: 1,
),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(AppTheme.spacingMd),
child: Column(
children: [
if (_turnstileToken == null) ...[
TurnstileWidget(
siteKey: _turnstileSiteKey,
onToken: (token) {
setState(() {
_turnstileToken = token;
});
},
),
] else ...[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle, color: AppTheme.success, size: 20),
const SizedBox(width: 8),
Text(
'Security verified',
style: TextStyle(color: AppTheme.success, fontWeight: FontWeight.w600),
),
],
),
],
],
),
),
const SizedBox(height: AppTheme.spacingLg),
if (_supportsBiometric) ...[
@ -422,7 +475,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
],
sojornButton(
label: 'Sign In',
onPressed: isSubmitting ? null : _signIn,
onPressed: (isSubmitting || _turnstileToken == null) ? null : _signIn,
isLoading: isSubmitting,
isFullWidth: true,
variant: sojornButtonVariant.primary,

View file

@ -1,9 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../providers/auth_provider.dart';
import '../../theme/app_theme.dart';
import 'profile_setup_screen.dart';
import 'category_select_screen.dart';
import '../../widgets/auth/turnstile_widget.dart';
class SignUpScreen extends ConsumerStatefulWidget {
const SignUpScreen({super.key});
@ -21,6 +23,22 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
final _confirmPasswordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
// Turnstile token
String? _turnstileToken;
// Legal consent
bool _acceptTerms = false;
bool _acceptPrivacy = false;
// Email preferences (single combined option)
bool _emailUpdates = false;
// 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() {
@ -34,6 +52,29 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
Future<void> _signUp() async {
if (!_formKey.currentState!.validate()) return;
// Validate Turnstile token
if (_turnstileToken == null || _turnstileToken!.isEmpty) {
setState(() {
_errorMessage = 'Please complete the security verification';
});
return;
}
// Validate legal consent
if (!_acceptTerms) {
setState(() {
_errorMessage = 'You must accept the Terms of Service';
});
return;
}
if (!_acceptPrivacy) {
setState(() {
_errorMessage = 'You must accept the Privacy Policy';
});
return;
}
setState(() {
_isLoading = true;
@ -47,33 +88,37 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
password: _passwordController.text,
handle: _handleController.text.trim(),
displayName: _displayNameController.text.trim(),
turnstileToken: _turnstileToken!,
acceptTerms: _acceptTerms,
acceptPrivacy: _acceptPrivacy,
emailNewsletter: _emailUpdates,
emailContact: _emailUpdates,
);
// Navigate to category selection (skip profile setup as it's done)
if (mounted) {
// Show success message and navigate to sign in
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Verify your email'),
content: const Text(
'A verification link has been sent to your email. Please check your inbox (and spam folder) to verify your account before logging in.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // dialog
Navigator.of(context).pop(); // signup screen
},
child: const Text('OK'),
),
],
),
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Verify your email'),
content: const Text(
'A verification link has been sent to your email. Please check your inbox (and spam folder) to verify your account before logging in.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // dialog
Navigator.of(context).pop(); // signup screen
},
child: const Text('OK'),
),
],
),
);
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
_turnstileToken = null; // Reset Turnstile on error
});
}
} finally {
@ -84,8 +129,13 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
}
}
}
void _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
@ -113,31 +163,26 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
const SizedBox(height: AppTheme.spacingSm),
Text(
'Your vibrant journey begins now', // Updated tagline
'Your vibrant journey begins now',
style: AppTheme.bodyMedium.copyWith(
color: AppTheme.navyText.withOpacity(
0.8), // Replaced AppTheme.textSecondary
color: AppTheme.navyText.withOpacity(0.8),
),
textAlign: TextAlign.center,
),
const SizedBox(
height: AppTheme.spacingLg *
1.5), // Replaced AppTheme.spacing2xl
const SizedBox(height: AppTheme.spacingLg * 1.5),
// Error message
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(AppTheme.spacingMd),
decoration: BoxDecoration(
color: AppTheme.error
.withOpacity(0.1), // Replaced withValues
color: AppTheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.error, width: 1),
),
child: Text(
_errorMessage!,
style: AppTheme.textTheme.labelSmall?.copyWith(
// Replaced AppTheme.bodySmall
color: AppTheme.error,
),
),
@ -151,10 +196,15 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
decoration: const InputDecoration(
labelText: 'Handle (@username)',
hintText: 'sojorn_user',
prefixIcon: Icon(Icons.alternate_email),
),
validator: (value) {
if (value == null || value.isEmpty) return 'Handle is required';
return null;
if (value == null || value.isEmpty) return 'Handle is required';
if (value.length < 3) return 'Handle must be at least 3 characters';
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
return 'Handle can only contain letters, numbers, and underscores';
}
return null;
},
),
const SizedBox(height: AppTheme.spacingMd),
@ -165,10 +215,11 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
decoration: const InputDecoration(
labelText: 'Display Name',
hintText: 'Jane Doe',
prefixIcon: Icon(Icons.person_outline),
),
validator: (value) {
if (value == null || value.isEmpty) return 'Display Name is required';
return null;
if (value == null || value.isEmpty) return 'Display Name is required';
return null;
},
),
const SizedBox(height: AppTheme.spacingMd),
@ -180,12 +231,13 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'your@email.com',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
if (!value.contains('@')) {
if (!value.contains('@') || !value.contains('.')) {
return 'Enter a valid email';
}
return null;
@ -200,6 +252,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
decoration: const InputDecoration(
labelText: 'Password',
hintText: 'At least 6 characters',
prefixIcon: Icon(Icons.lock_outline),
),
validator: (value) {
if (value == null || value.isEmpty) {
@ -219,6 +272,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
obscureText: true,
decoration: const InputDecoration(
labelText: 'Confirm Password',
prefixIcon: Icon(Icons.lock_outline),
),
validator: (value) {
if (value == null || value.isEmpty) {
@ -232,30 +286,131 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
),
const SizedBox(height: AppTheme.spacingLg),
// Turnstile CAPTCHA
Container(
decoration: BoxDecoration(
border: Border.all(
color: _turnstileToken != null
? AppTheme.success
: AppTheme.egyptianBlue.withOpacity(0.3),
width: 1,
),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(AppTheme.spacingMd),
child: Column(
children: [
if (_turnstileToken == null) ...[
TurnstileWidget(
siteKey: _turnstileSiteKey,
onToken: (token) {
setState(() {
_turnstileToken = token;
});
},
),
] else ...[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle, color: AppTheme.success, size: 20),
const SizedBox(width: 8),
Text(
'Security verified',
style: TextStyle(color: AppTheme.success, fontWeight: FontWeight.w600),
),
],
),
],
],
),
),
const SizedBox(height: AppTheme.spacingLg),
// Terms of Service checkbox
CheckboxListTile(
value: _acceptTerms,
onChanged: (value) => setState(() => _acceptTerms = value ?? false),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
title: RichText(
text: TextSpan(
style: AppTheme.textTheme.bodySmall?.copyWith(color: AppTheme.navyText),
children: [
const TextSpan(text: 'I agree to the '),
TextSpan(
text: 'Terms of Service',
style: TextStyle(color: AppTheme.brightNavy, fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => _launchUrl('https://sojorn.net/terms'),
),
],
),
),
),
// Privacy Policy checkbox
CheckboxListTile(
value: _acceptPrivacy,
onChanged: (value) => setState(() => _acceptPrivacy = value ?? false),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
title: RichText(
text: TextSpan(
style: AppTheme.textTheme.bodySmall?.copyWith(color: AppTheme.navyText),
children: [
const TextSpan(text: 'I agree to the '),
TextSpan(
text: 'Privacy Policy',
style: TextStyle(color: AppTheme.brightNavy, fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => _launchUrl('https://sojorn.net/privacy'),
),
],
),
),
),
// Email updates preference (part of agreement section)
CheckboxListTile(
value: _emailUpdates,
onChanged: (value) => setState(() => _emailUpdates = value ?? false),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(
'Please send me email updates about sojorn and MPLS LLC',
style: AppTheme.textTheme.bodySmall?.copyWith(color: AppTheme.navyText),
),
),
const SizedBox(height: AppTheme.spacingMd),
// Sign up button
ElevatedButton(
onPressed: _isLoading ? null : _signUp,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation(Colors.white),
valueColor: AlwaysStoppedAnimation(Colors.white),
),
)
: const Text('Continue'),
: const Text('Create Account'),
),
const SizedBox(height: AppTheme.spacingMd),
// Terms and privacy note
// Footer
Text(
'By continuing, you agree to our vibrant community guidelines.\nA product of MPLS LLC.', // Updated text
'A product of MPLS LLC.',
style: AppTheme.textTheme.labelSmall?.copyWith(
// Replaced AppTheme.bodySmall
color: AppTheme
.egyptianBlue, // Replaced AppTheme.textTertiary
color: AppTheme.egyptianBlue,
),
textAlign: TextAlign.center,
),

View file

@ -18,9 +18,11 @@ import '../secure_chat/secure_chat_full_screen.dart';
import '../../services/notification_service.dart';
import '../../widgets/post/post_body.dart';
import '../../widgets/post/post_view_mode.dart';
import '../../widgets/post/post_media.dart';
import '../../providers/notification_provider.dart';
import 'package:share_plus/share_plus.dart';
class ThreadedConversationScreen extends ConsumerStatefulWidget {
final String rootPostId;
final Post? rootPost;
@ -495,10 +497,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
_buildStageHeader(focalPost),
const SizedBox(height: 16),
_buildStageContent(focalPost),
if (focalPost.imageUrl != null) ...[
if (focalPost.imageUrl != null || focalPost.videoUrl != null || focalPost.thumbnailUrl != null) ...[
const SizedBox(height: 16),
_buildStageMedia(focalPost.imageUrl!),
PostMedia(
post: focalPost,
mode: PostViewMode.detail,
),
],
const SizedBox(height: 20),
_buildStageActions(focalPost),
],
@ -1190,7 +1196,13 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
Future<void> _sharePost(Post post) async {
final handle = post.author?.handle ?? 'sojorn';
final text = '${post.body}\n\n— @$handle on sojorn';
final shareUrl = post.hasVideoContent == true
? AppRoutes.getQuipUrl(post.id)
: AppRoutes.getPostUrl(post.id);
final text = '${post.body}\n\n$shareUrl\n\n— @$handle on Sojorn';
try {
await Share.share(text);

View file

@ -67,7 +67,9 @@ class Quip {
class QuipsFeedScreen extends ConsumerStatefulWidget {
final bool? isActive;
const QuipsFeedScreen({super.key, this.isActive});
final String? initialPostId;
const QuipsFeedScreen({super.key, this.isActive, this.initialPostId});
@override
ConsumerState<QuipsFeedScreen> createState() => _QuipsFeedScreenState();
@ -99,9 +101,15 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
super.initState();
WidgetsBinding.instance.addObserver(this);
_isScreenActive = widget.isActive ?? false;
_fetchQuips();
if (widget.initialPostId != null) {
_isUserPaused = false;
}
_fetchQuips(refresh: widget.initialPostId != null);
}
void _checkFeedRefresh() {
final refreshToken = ref.read(feedRefreshProvider);
if (refreshToken != _lastRefreshToken) {
@ -135,8 +143,14 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
if (widget.isActive != oldWidget.isActive) {
_handleScreenActive(_resolveActiveState());
}
if (widget.initialPostId != oldWidget.initialPostId && widget.initialPostId != null) {
_isUserPaused = false; // Auto-play if user explicitly clicked a quip
_fetchQuips(refresh: true);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@ -227,11 +241,43 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
final posts =
(data['posts'] as List? ?? []).whereType<Map<String, dynamic>>();
final items = posts
List<Quip> items = posts
.map(Quip.fromMap)
.where((quip) => quip.videoUrl.isNotEmpty)
.toList();
// If we have an initialPostId, ensure it's at the top
// If we have an initialPostId, ensure it's at the top
if (refresh && widget.initialPostId != null) {
print('[Quips] Handling initialPostId: ${widget.initialPostId}');
final existingIndex = items.indexWhere((q) => q.id == widget.initialPostId);
if (existingIndex != -1) {
print('[Quips] Found initialPostId in feed at index $existingIndex, moving to top');
final initial = items.removeAt(existingIndex);
items.insert(0, initial);
} else {
print('[Quips] initialPostId NOT in feed, fetching specifically...');
try {
final postData = await api.callGoApi('/posts/${widget.initialPostId}', method: 'GET');
if (postData['post'] != null) {
final quip = Quip.fromMap(postData['post'] as Map<String, dynamic>);
if (quip.videoUrl.isNotEmpty) {
print('[Quips] Successfully fetched initial quip: ${quip.videoUrl}');
items.insert(0, quip);
} else {
print('[Quips] Fetched post is not a video: ${quip.videoUrl}');
}
} else {
print('[Quips] No post found for initialPostId: ${widget.initialPostId}');
}
} catch (e) {
print('Initial quip fetch error: $e');
}
}
}
if (!mounted) return;
setState(() {
if (refresh) {
@ -419,10 +465,13 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
}
void _shareQuip(Quip quip) {
final url = AppRoutes.getPostUrl(quip.id);
Share.share(url);
final url = AppRoutes.getQuipUrl(quip.id);
final text = '${quip.caption}\n\n$url\n\n— @${quip.username} on Sojorn';
Share.share(text);
}
@override
Widget build(BuildContext context) {
if (_error != null) {

View file

@ -578,8 +578,14 @@ class ApiService {
if (long != null && (long < -180 || long > 180)) {
throw ArgumentError('Invalid longitude range');
}
if (kDebugMode) {
print('[Post] Publishing: body=$body, video=$videoUrl, thumb=$thumbnailUrl');
print('[Post] Sanitized: video=$sanitizedVideoUrl, thumb=$sanitizedThumbnailUrl');
}
final data = await _callGoApi(
'/posts',
method: 'POST',
body: {
@ -588,9 +594,13 @@ class ApiService {
'body_format': bodyFormat,
'allow_chain': allowChain,
if (chainParentId != null) 'chain_parent_id': chainParentId,
if (sanitizedImageUrl != null) 'image_url': sanitizedImageUrl,
if (sanitizedVideoUrl != null) 'video_url': sanitizedVideoUrl,
if (sanitizedThumbnailUrl != null) 'thumbnail_url': sanitizedThumbnailUrl,
if (sanitizedImageUrl != null || (imageUrl != null && imageUrl.isNotEmpty))
'image_url': sanitizedImageUrl ?? imageUrl,
if (sanitizedVideoUrl != null || (videoUrl != null && videoUrl.isNotEmpty))
'video_url': sanitizedVideoUrl ?? videoUrl,
if (sanitizedThumbnailUrl != null || (thumbnailUrl != null && thumbnailUrl.isNotEmpty))
'thumbnail_url': sanitizedThumbnailUrl ?? thumbnailUrl,
if (durationMs != null) 'duration_ms': durationMs,
if (ttlHours != null) 'ttl_hours': ttlHours,
if (isBeacon) 'is_beacon': true,
@ -762,11 +772,17 @@ class ApiService {
}
Future<void> blockUser(String userId) async {
// Migrate to Go API
await _callGoApi(
'/users/$userId/block',
method: 'POST',
);
}
Future<void> unblockUser(String userId) async {
// Migrate to Go API
await _callGoApi(
'/users/$userId/block',
method: 'DELETE',
);
}
Future<void> appreciatePost(String postId) async {
@ -846,7 +862,7 @@ class ApiService {
await _callGoApi('/conversations/$conversationId', method: 'DELETE');
return true;
} catch (e) {
print('[API] Failed to delete conversation: $e');
if (kDebugMode) print('[API] Failed to delete conversation: $e');
return false;
}
}
@ -856,7 +872,7 @@ class ApiService {
await _callGoApi('/messages/$messageId', method: 'DELETE');
return true;
} catch (e) {
print('[API] Failed to delete message: $e');
if (kDebugMode) print('[API] Failed to delete message: $e');
return false;
}
}
@ -867,7 +883,7 @@ class ApiService {
Future<Map<String, dynamic>> getKeyBundle(String userId) async {
final data = await callGoApi('/keys/$userId', method: 'GET');
print('[API] Raw Key Bundle for $userId: ${jsonEncode(data)}');
// Key bundle fetched - contents not logged for security
// Go returns nested structure. We normalize to flat keys here.
if (data.containsKey('identity_key') && data['identity_key'] is Map) {
final identityKey = data['identity_key'] as Map<String, dynamic>;

View file

@ -200,13 +200,18 @@ class AuthService {
Future<Map<String, dynamic>> signInWithGoBackend({
required String email,
required String password,
required String turnstileToken,
}) async {
try {
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login');
final response = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
body: jsonEncode({
'email': email,
'password': password,
'turnstile_token': turnstileToken,
}),
);
final data = jsonDecode(response.body);
@ -253,6 +258,11 @@ class AuthService {
required String password,
required String handle,
required String displayName,
required String turnstileToken,
required bool acceptTerms,
required bool acceptPrivacy,
bool emailNewsletter = false,
bool emailContact = false,
}) async {
try {
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register');
@ -264,6 +274,11 @@ class AuthService {
'password': password,
'handle': handle,
'display_name': displayName,
'turnstile_token': turnstileToken,
'accept_terms': acceptTerms,
'accept_privacy': acceptPrivacy,
'email_newsletter': emailNewsletter,
'email_contact': emailContact,
}),
);

View file

@ -73,8 +73,8 @@ class SimpleE2EEService {
return _initFuture = _doInitialize(userId);
}
// DEBUG: Set to true to force new key generation on startup (fixing bad keys)
static const bool _FORCE_KEY_ROTATION = false;
// Key rotation is now handled via initiateKeyRecovery() when needed
// DO NOT add debug flags here - use resetAllKeys() method for intentional resets
Future<void> resetAllKeys() async {
print('[E2EE] RESETTING ALL KEYS - fixing MAC errors');
@ -202,11 +202,7 @@ class SimpleE2EEService {
Future<void> _doInitialize(String userId) async {
_initializedForUserId = userId;
if (_FORCE_KEY_ROTATION) {
print('[E2EE] FORCE_KEY_ROTATION is true. Skipping load/restore and generating NEW identity.');
await generateNewIdentity();
return;
}
// 1. Try Local Storage
try {
@ -640,7 +636,7 @@ class SimpleE2EEService {
final secretBox = SecretBox(ciphertextBytes, nonce: nonce, mac: Mac(macBytes));
final plaintextBytes = await _cipher.decrypt(secretBox, secretKey: SecretKey(rootSecret));
final plaintext = utf8.decode(plaintextBytes);
print('[DECRYPT] SUCCESS: Decrypted message: "$plaintext"');
// Decryption successful - plaintext not logged for security
return plaintext;
} catch (e) {
print('[DECRYPT] Failed: $e');

View file

@ -62,8 +62,9 @@ class SecurityUtils {
final dangerousPatterns = [
'script', 'javascript', 'vbscript', 'onload', 'onerror', 'onclick',
'eval', 'expression', 'alert', 'confirm', 'prompt',
'<', '>', '"', "'", '\\', '/', '\n', '\r', '\t'
'<', '>', '"', "'", '\\', '\n', '\r', '\t'
];
final lowerValue = value.toLowerCase();
return dangerousPatterns.any((pattern) => lowerValue.contains(pattern));

View file

@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../models/post.dart';
import '../../routes/app_routes.dart';
import '../../theme/app_theme.dart';
import '../media/signed_media_image.dart';
import 'post_view_mode.dart';
@ -14,62 +17,74 @@ class PostMedia extends StatelessWidget {
final Post? post;
final Widget? child;
final PostViewMode mode;
final VoidCallback? onTap;
const PostMedia({
super.key,
this.post,
this.child,
this.mode = PostViewMode.feed,
this.onTap,
});
/// Get image height based on view mode
double get _imageHeight {
switch (mode) {
case PostViewMode.feed:
return 300.0;
return 450.0; // Taller for better resolution/ratio
case PostViewMode.detail:
return 500.0; // Full height for detail view
return 600.0;
case PostViewMode.compact:
return 200.0; // Smaller for profile lists
return 200.0;
}
}
@override
Widget build(BuildContext context) {
if (post != null && post!.imageUrl != null && post!.imageUrl!.isNotEmpty) {
// Determine which URL to display as the cover
final String? displayUrl = (post?.imageUrl?.isNotEmpty == true)
? post!.imageUrl
: (post?.thumbnailUrl?.isNotEmpty == true)
? post!.thumbnailUrl
: null;
if (displayUrl != null) {
final bool isVideo = post?.hasVideoContent == true;
return Padding(
padding: const EdgeInsets.only(top: AppTheme.spacingSm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: _imageHeight),
child: SizedBox(
ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: Container(
width: double.infinity,
child: SignedMediaImage(
url: post!.imageUrl!,
fit: BoxFit.cover,
loadingBuilder: (context) => Container(
color: AppTheme.queenPink.withValues(alpha: 0.3),
child: const Center(child: CircularProgressIndicator()),
),
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.red.withValues(alpha: 0.3),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.broken_image,
size: 48, color: Colors.white),
const SizedBox(height: 8),
Text('Error: $error',
style: const TextStyle(
color: Colors.white, fontSize: 10)),
],
),
),
),
// For videos in feed mode, use a more vertical 4:5 aspect ratio
// For other modes or non-videos, use the constrained height logic
child: InkWell(
onTap: isVideo
? () {
final url = '${AppRoutes.quips}?postId=${post!.id}';
print('[PostMedia] Navigating to quips: $url');
context.go(url);
}
: onTap,
child: (isVideo && mode == PostViewMode.feed)
? AspectRatio(
aspectRatio: 4 / 5,
child: _buildMediaContent(displayUrl, true),
)
: ConstrainedBox(
constraints: BoxConstraints(maxHeight: _imageHeight),
child: _buildMediaContent(displayUrl, isVideo),
),
),
),
),
],
@ -89,4 +104,52 @@ class PostMedia extends StatelessWidget {
),
);
}
Widget _buildMediaContent(String displayUrl, bool isVideo) {
return Stack(
fit: StackFit.expand,
children: [
SignedMediaImage(
url: displayUrl,
fit: (isVideo && mode == PostViewMode.feed) ? BoxFit.cover : BoxFit.cover,
loadingBuilder: (context) => Container(
color: AppTheme.queenPink.withValues(alpha: 0.3),
child: const Center(child: CircularProgressIndicator()),
),
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.red.withValues(alpha: 0.3),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.broken_image, size: 48, color: Colors.white),
const SizedBox(height: 8),
Text('Error: $error',
style: const TextStyle(color: Colors.white, fontSize: 10)),
],
),
),
),
),
// Play Button Overlay for Video
if (isVideo)
Center(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 40,
),
),
),
],
);
}
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../models/post.dart';
import '../theme/app_theme.dart';
import 'post/post_actions.dart';
import 'post/post_body.dart';
@ -195,18 +196,21 @@ class sojornPostCard extends StatelessWidget {
),
),
// Media (if available) - clickable for post detail
if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[
// Media (if available) - clickable for post detail (or quip player if video)
if ((post.imageUrl != null && post.imageUrl!.isNotEmpty) ||
(post.thumbnailUrl != null && post.thumbnailUrl!.isNotEmpty) ||
(post.videoUrl != null && post.videoUrl!.isNotEmpty)) ...[
const SizedBox(height: 12),
InkWell(
PostMedia(
post: post,
mode: mode,
onTap: onTap,
child: PostMedia(
post: post,
mode: mode,
),
),
],
// Actions section - with padding
const SizedBox(height: 16),
Padding(

View file

@ -27,7 +27,7 @@ dependencies:
google_fonts: ^6.2.1
share_plus: ^10.0.2
timeago: ^3.7.0
url_launcher: ^6.3.1
url_launcher: ^6.3.2
image_picker: ^1.1.2
image: ^4.3.0
flutter_image_compress: ^2.4.0

View file

@ -40,12 +40,17 @@ $defineArgs = @(
"--dart-define=API_BASE_URL=$($values['API_BASE_URL'])"
)
if ($values.ContainsKey('TURNSTILE_SITE_KEY') -and -not [string]::IsNullOrWhiteSpace($values['TURNSTILE_SITE_KEY'])) {
$defineArgs += "--dart-define=TURNSTILE_SITE_KEY=$($values['TURNSTILE_SITE_KEY'])"
}
Write-Host "Starting Sojorn in development mode..." -ForegroundColor Green
Write-Host ""
Push-Location $PSScriptRoot
try {
flutter run @defineArgs @Args
} finally {
}
finally {
Pop-Location
}

197
verified_fixed.html Normal file
View file

@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Verification - Sojorn</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #10B981;
--primary-dark: #059669;
--warning: #F59E0B;
--bg: #09090b;
--card: #18181b;
--text: #ffffff;
--text-muted: #a1a1aa;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: var(--bg);
color: var(--text);
overflow: hidden;
}
.container {
text-align: center;
background: var(--card);
padding: 3rem;
border-radius: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
max-width: 440px;
width: 90%;
border: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
z-index: 10;
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.icon-wrapper {
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
transition: all 0.3s ease;
}
.icon-wrapper.success { background: rgba(16, 185, 129, 0.1); }
.icon-wrapper.pending { background: rgba(245, 158, 11, 0.1); }
.icon {
width: 40px;
height: 40px;
}
.icon.success { color: var(--primary); }
.icon.pending { color: var(--warning); }
h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.75rem;
background: linear-gradient(to bottom right, #ffffff, #a1a1aa);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
p {
font-size: 1.1rem;
margin-bottom: 2rem;
color: var(--text-muted);
line-height: 1.6;
}
.btn {
display: inline-block;
background-color: var(--primary);
color: white;
padding: 1rem 2.5rem;
text-decoration: none;
border-radius: 12px;
font-weight: 600;
font-size: 1.1rem;
transition: all 0.2s ease;
box-shadow: 0 10px 15px -3px rgba(16, 185, 129, 0.4);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(16, 185, 129, 0.4);
}
.loader {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.dot {
width: 4px;
height: 4px;
background: var(--primary);
border-radius: 50%;
animation: blink 1.4s infinite both;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink {
0%, 80%, 100% { opacity: 0; }
40% { opacity: 1; }
}
.bg-gradient {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: radial-gradient(circle at 50% 50%, rgba(16, 185, 129, 0.05) 0%, rgba(9, 9, 11, 1) 70%);
z-index: 1;
}
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="bg-gradient"></div>
<div class="container">
<!-- Success State -->
<div id="success-state" class="hidden">
<div class="icon-wrapper success">
<svg class="icon success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h1>Email Verified</h1>
<p>Your email has been successfully verified. You're all set to experience Sojorn.</p>
<a href="sojorn://verified" class="btn">Open Sojorn App</a>
<div class="loader">
<span>Redirecting to app</span>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</div>
<!-- Pending State (Accessing URL directly) -->
<div id="pending-state">
<div class="icon-wrapper pending">
<svg class="icon pending" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<h1>Verification Required</h1>
<p>We couldn't confirm your verification status. Please use the link sent to your email address.</p>
<a href="sojorn://login" class="btn" style="background-color: #3f3f46; box-shadow: none;">Return to App</a>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('status') === 'success') {
document.getElementById('pending-state').classList.add('hidden');
document.getElementById('success-state').classList.remove('hidden');
// Auto-redirect to app
setTimeout(function () {
window.location.href = "sojorn://verified";
}, 2000);
}
</script>
</body>
</html>