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 R2MediaBucket string
R2VideoBucket string R2VideoBucket string
TurnstileSecretKey string TurnstileSecretKey string
APIBaseURL string
AppBaseURL string
} }
func LoadConfig() *Config { func LoadConfig() *Config {
@ -78,6 +80,8 @@ func LoadConfig() *Config {
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", ""), 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 { type LoginRequest struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
TurnstileToken string `json:"turnstile_token" binding:"required"`
} }
func (h *AuthHandler) Register(c *gin.Context) { 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)) 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) user, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email)
if err != nil { if err != nil {
log.Printf("[Auth] Login failed for %s: user not found", req.Email) 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) { func (h *AuthHandler) VerifyEmail(c *gin.Context) {
rawToken := c.Query("token") rawToken := c.Query("token")
if rawToken == "" { 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 return
} }
@ -243,19 +261,19 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString) userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString)
if err != nil { 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 return
} }
if time.Now().After(expiresAt) { if time.Now().After(expiresAt) {
h.repo.DeleteVerificationToken(c.Request.Context(), hashString) 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 return
} }
// Activate user // Activate user
if err := h.repo.UpdateUserStatus(c.Request.Context(), userID, models.UserStatusActive); err != nil { 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 return
} }
@ -275,7 +293,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
// Cleanup // Cleanup
_ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString) _ = 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) { 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 { if err != nil {
return fmt.Errorf("failed to delete used OTK: %w", err) 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 return nil
} }
@ -613,9 +613,7 @@ func (r *UserRepository) GetSignalKeyBundle(ctx context.Context, userID string)
"key_id": otkID, "key_id": otkID,
"public_key": otkPub, "public_key": otkPub,
} }
fmt.Printf("[KEYS] Retrieved OTK #%d for user %s\n", otkID, userID) // OTK retrieved - not logging user ID for security
} else {
fmt.Printf("[KEYS] No OTKs available for user %s\n", userID)
} }
// Handle NULL values properly // Handle NULL values properly

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
@ -51,65 +52,55 @@ type sendPulseIdentity struct {
func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error { func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error {
subject := "Verify your Sojorn account" 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(` title := "Email Verification"
<!DOCTYPE html> header := fmt.Sprintf("Hey %s! 👋", toName)
<html> if toName == "" {
<head> header = "Hey there! 👋"
<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)
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 { func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error {
subject := "Reset your Sojorn password" 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(` title := "Password Reset"
<h2>Reset Password for %s</h2> header := "Reset your password"
<p>You requested a password reset. Click the link below to set a new password:</p> content := fmt.Sprintf(`
<p><a href="%s" style="padding: 10px 20px; background-color: #dc3545; color: white; text-decoration: none; border-radius: 5px;">Reset Password</a></p> <p>Hey %s,</p>
<p>This link expires in 1 hour.</p> <p>You requested a password reset for your Sojorn account. Click the button below to set a new password:</p>
<p>If you did not request this, please ignore this email.</p> `, toName)
`, toName, resetURL)
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 { 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, 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 { if err != nil {
log.Error().Err(err).Msg("Failed to send email via SMTP") log.Error().Err(err).Msg("Failed to send email via SMTP")
return err return err
@ -256,3 +255,51 @@ func (s *EmailService) AddSubscriber(email, name string) {
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first // SendPulse Addressbook API implementation omitted for brevity, focusing on email first
// Endpoint: POST /addressbooks/{id}/emails // 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 = @( $optionalDefines = @(
'FIREBASE_WEB_VAPID_KEY', 'FIREBASE_WEB_VAPID_KEY',
'SUPABASE_PUBLISHABLE_KEY', 'TURNSTILE_SITE_KEY'
'SUPABASE_SECRET_KEY',
'SUPABASE_JWT_KID',
'SUPABASE_JWKS_URI'
) )
foreach ($opt in $optionalDefines) { foreach ($opt in $optionalDefines) {
@ -57,6 +54,7 @@ foreach ($opt in $optionalDefines) {
Push-Location (Join-Path $PSScriptRoot "sojorn_app") Push-Location (Join-Path $PSScriptRoot "sojorn_app")
try { try {
flutter run @defineArgs @Args flutter run @defineArgs @Args
} finally { }
finally {
Pop-Location Pop-Location
} }

View file

@ -32,7 +32,7 @@ $values = Parse-Env $EnvPath
# Collect dart-defines we actually use on web. # Collect dart-defines we actually use on web.
$defineArgs = @() $defineArgs = @()
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY') $keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
foreach ($k in $keysOfInterest) { foreach ($k in $keysOfInterest) {
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) { if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
$defineArgs += "--dart-define=$k=$($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. # Collect dart-defines we actually use on web.
$defineArgs = @() $defineArgs = @()
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY') $keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
foreach ($k in $keysOfInterest) { foreach ($k in $keysOfInterest) {
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) { if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
$defineArgs += "--dart-define=$k=$($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. # Collect dart-defines we actually use on Windows.
$defineArgs = @() $defineArgs = @()
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY') $keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
foreach ($k in $keysOfInterest) { foreach ($k in $keysOfInterest) {
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) { if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
$defineArgs += "--dart-define=$k=$($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?, videoUrl: json['video_url'] as String?,
thumbnailUrl: json['thumbnail_url'] as String?, thumbnailUrl: json['thumbnail_url'] as String?,
durationMs: _parseInt(json['duration_ms']), 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?, bodyFormat: json['body_format'] as String?,
backgroundId: json['background_id'] as String?, backgroundId: json['background_id'] as String?,
tags: _parseTags(json['tags']), tags: _parseTags(json['tags']),
reactions: _parseReactions( reactions: _parseReactions(

View file

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

View file

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

View file

@ -31,7 +31,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
bool _saveCredentials = true; bool _saveCredentials = true;
String? _storedEmail; String? _storedEmail;
String? _storedPassword; 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 _savedEmailKey = 'saved_login_email';
static const _savedPasswordKey = 'saved_login_password'; static const _savedPasswordKey = 'saved_login_password';
@ -94,14 +100,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
bool get _canUseBiometricLogin => bool get _canUseBiometricLogin =>
_supportsBiometric && _supportsBiometric &&
_hasStoredCredentials && _hasStoredCredentials &&
!_isBiometricAuthenticating; !_isBiometricAuthenticating &&
_turnstileToken != null; // Require Turnstile for biometric too
Future<void> _signIn() async { Future<void> _signIn() async {
final email = _emailController.text.trim(); final email = _emailController.text.trim();
final password = _passwordController.text; final password = _passwordController.text;
print('[SignIn] Attempting sign-in for $email');
if (email.isEmpty || !email.contains('@')) { if (email.isEmpty || !email.contains('@')) {
setState(() { setState(() {
_errorMessage = 'Please enter a valid email address'; _errorMessage = 'Please enter a valid email address';
@ -116,6 +121,14 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
return; return;
} }
// Validate Turnstile token
if (_turnstileToken == null || _turnstileToken!.isEmpty) {
setState(() {
_errorMessage = 'Please complete the security verification';
});
return;
}
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@ -123,18 +136,18 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
try { try {
final authService = ref.read(authServiceProvider); final authService = ref.read(authServiceProvider);
print('[SignIn] Calling signInWithGoBackend...');
await authService.signInWithGoBackend( await authService.signInWithGoBackend(
email: email, email: email,
password: password, password: password,
turnstileToken: _turnstileToken!,
); );
print('[SignIn] Sign-in successful!');
await _persistCredentials(email, password); await _persistCredentials(email, password);
} catch (e) { } catch (e) {
print('[SignIn] Error: $e');
if (mounted) { if (mounted) {
setState(() { setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', ''); _errorMessage = e.toString().replaceAll('Exception: ', '');
// Reset Turnstile token on error so user must re-verify
_turnstileToken = null;
}); });
} }
} finally { } finally {
@ -382,7 +395,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
obscureText: true, obscureText: true,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
prefixIcon: Icons.lock_outline, prefixIcon: Icons.lock_outline,
onEditingComplete: _signIn, onEditingComplete: _turnstileToken != null ? _signIn : null,
autofillHints: const [AutofillHints.password], autofillHints: const [AutofillHints.password],
onChanged: (_) { onChanged: (_) {
if (_errorMessage != null) { if (_errorMessage != null) {
@ -393,6 +406,46 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
}, },
), ),
const SizedBox(height: AppTheme.spacingLg), 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), const SizedBox(height: AppTheme.spacingLg),
if (_supportsBiometric) ...[ if (_supportsBiometric) ...[
@ -422,7 +475,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
], ],
sojornButton( sojornButton(
label: 'Sign In', label: 'Sign In',
onPressed: isSubmitting ? null : _signIn, onPressed: (isSubmitting || _turnstileToken == null) ? null : _signIn,
isLoading: isSubmitting, isLoading: isSubmitting,
isFullWidth: true, isFullWidth: true,
variant: sojornButtonVariant.primary, 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import 'profile_setup_screen.dart'; import '../../widgets/auth/turnstile_widget.dart';
import 'category_select_screen.dart';
class SignUpScreen extends ConsumerStatefulWidget { class SignUpScreen extends ConsumerStatefulWidget {
const SignUpScreen({super.key}); const SignUpScreen({super.key});
@ -22,6 +24,22 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
bool _isLoading = false; bool _isLoading = false;
String? _errorMessage; 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 @override
void dispose() { void dispose() {
_emailController.dispose(); _emailController.dispose();
@ -35,6 +53,29 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
Future<void> _signUp() async { Future<void> _signUp() async {
if (!_formKey.currentState!.validate()) return; 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(() { setState(() {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
@ -47,33 +88,37 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
password: _passwordController.text, password: _passwordController.text,
handle: _handleController.text.trim(), handle: _handleController.text.trim(),
displayName: _displayNameController.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) { if (mounted) {
// Show success message and navigate to sign in showDialog(
showDialog( context: context,
context: context, builder: (context) => AlertDialog(
builder: (context) => AlertDialog( title: const Text('Verify your email'),
title: const Text('Verify your email'), content: const Text(
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.'),
'A verification link has been sent to your email. Please check your inbox (and spam folder) to verify your account before logging in.'), actions: [
actions: [ TextButton(
TextButton( onPressed: () {
onPressed: () { Navigator.of(context).pop(); // dialog
Navigator.of(context).pop(); // dialog Navigator.of(context).pop(); // signup screen
Navigator.of(context).pop(); // signup screen },
}, child: const Text('OK'),
child: const Text('OK'), ),
), ],
], ),
), );
);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', ''); _errorMessage = e.toString().replaceAll('Exception: ', '');
_turnstileToken = null; // Reset Turnstile on error
}); });
} }
} finally { } finally {
@ -85,7 +130,12 @@ 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -113,31 +163,26 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
const SizedBox(height: AppTheme.spacingSm), const SizedBox(height: AppTheme.spacingSm),
Text( Text(
'Your vibrant journey begins now', // Updated tagline 'Your vibrant journey begins now',
style: AppTheme.bodyMedium.copyWith( style: AppTheme.bodyMedium.copyWith(
color: AppTheme.navyText.withOpacity( color: AppTheme.navyText.withOpacity(0.8),
0.8), // Replaced AppTheme.textSecondary
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox( const SizedBox(height: AppTheme.spacingLg * 1.5),
height: AppTheme.spacingLg *
1.5), // Replaced AppTheme.spacing2xl
// Error message // Error message
if (_errorMessage != null) ...[ if (_errorMessage != null) ...[
Container( Container(
padding: const EdgeInsets.all(AppTheme.spacingMd), padding: const EdgeInsets.all(AppTheme.spacingMd),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.error color: AppTheme.error.withOpacity(0.1),
.withOpacity(0.1), // Replaced withValues
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.error, width: 1), border: Border.all(color: AppTheme.error, width: 1),
), ),
child: Text( child: Text(
_errorMessage!, _errorMessage!,
style: AppTheme.textTheme.labelSmall?.copyWith( style: AppTheme.textTheme.labelSmall?.copyWith(
// Replaced AppTheme.bodySmall
color: AppTheme.error, color: AppTheme.error,
), ),
), ),
@ -151,10 +196,15 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Handle (@username)', labelText: 'Handle (@username)',
hintText: 'sojorn_user', hintText: 'sojorn_user',
prefixIcon: Icon(Icons.alternate_email),
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) return 'Handle is required'; if (value == null || value.isEmpty) return 'Handle is required';
return null; 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), const SizedBox(height: AppTheme.spacingMd),
@ -165,10 +215,11 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Display Name', labelText: 'Display Name',
hintText: 'Jane Doe', hintText: 'Jane Doe',
prefixIcon: Icon(Icons.person_outline),
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) return 'Display Name is required'; if (value == null || value.isEmpty) return 'Display Name is required';
return null; return null;
}, },
), ),
const SizedBox(height: AppTheme.spacingMd), const SizedBox(height: AppTheme.spacingMd),
@ -180,12 +231,13 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Email', labelText: 'Email',
hintText: 'your@email.com', hintText: 'your@email.com',
prefixIcon: Icon(Icons.email_outlined),
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Email is required'; return 'Email is required';
} }
if (!value.contains('@')) { if (!value.contains('@') || !value.contains('.')) {
return 'Enter a valid email'; return 'Enter a valid email';
} }
return null; return null;
@ -200,6 +252,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Password', labelText: 'Password',
hintText: 'At least 6 characters', hintText: 'At least 6 characters',
prefixIcon: Icon(Icons.lock_outline),
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
@ -219,6 +272,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
obscureText: true, obscureText: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Confirm Password', labelText: 'Confirm Password',
prefixIcon: Icon(Icons.lock_outline),
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
@ -232,30 +286,131 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
), ),
const SizedBox(height: AppTheme.spacingLg), 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 // Sign up button
ElevatedButton( ElevatedButton(
onPressed: _isLoading ? null : _signUp, onPressed: _isLoading ? null : _signUp,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading child: _isLoading
? const SizedBox( ? const SizedBox(
height: 20, height: 20,
width: 20, width: 20,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: valueColor: AlwaysStoppedAnimation(Colors.white),
AlwaysStoppedAnimation(Colors.white),
), ),
) )
: const Text('Continue'), : const Text('Create Account'),
), ),
const SizedBox(height: AppTheme.spacingMd), const SizedBox(height: AppTheme.spacingMd),
// Terms and privacy note // Footer
Text( 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( style: AppTheme.textTheme.labelSmall?.copyWith(
// Replaced AppTheme.bodySmall color: AppTheme.egyptianBlue,
color: AppTheme
.egyptianBlue, // Replaced AppTheme.textTertiary
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),

View file

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

View file

@ -67,7 +67,9 @@ class Quip {
class QuipsFeedScreen extends ConsumerStatefulWidget { class QuipsFeedScreen extends ConsumerStatefulWidget {
final bool? isActive; final bool? isActive;
const QuipsFeedScreen({super.key, this.isActive}); final String? initialPostId;
const QuipsFeedScreen({super.key, this.isActive, this.initialPostId});
@override @override
ConsumerState<QuipsFeedScreen> createState() => _QuipsFeedScreenState(); ConsumerState<QuipsFeedScreen> createState() => _QuipsFeedScreenState();
@ -99,9 +101,15 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_isScreenActive = widget.isActive ?? false; _isScreenActive = widget.isActive ?? false;
_fetchQuips(); if (widget.initialPostId != null) {
_isUserPaused = false;
}
_fetchQuips(refresh: widget.initialPostId != null);
} }
void _checkFeedRefresh() { void _checkFeedRefresh() {
final refreshToken = ref.read(feedRefreshProvider); final refreshToken = ref.read(feedRefreshProvider);
if (refreshToken != _lastRefreshToken) { if (refreshToken != _lastRefreshToken) {
@ -135,8 +143,14 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
if (widget.isActive != oldWidget.isActive) { if (widget.isActive != oldWidget.isActive) {
_handleScreenActive(_resolveActiveState()); _handleScreenActive(_resolveActiveState());
} }
if (widget.initialPostId != oldWidget.initialPostId && widget.initialPostId != null) {
_isUserPaused = false; // Auto-play if user explicitly clicked a quip
_fetchQuips(refresh: true);
}
} }
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@ -227,11 +241,43 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
final posts = final posts =
(data['posts'] as List? ?? []).whereType<Map<String, dynamic>>(); (data['posts'] as List? ?? []).whereType<Map<String, dynamic>>();
final items = posts List<Quip> items = posts
.map(Quip.fromMap) .map(Quip.fromMap)
.where((quip) => quip.videoUrl.isNotEmpty) .where((quip) => quip.videoUrl.isNotEmpty)
.toList(); .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; if (!mounted) return;
setState(() { setState(() {
if (refresh) { if (refresh) {
@ -419,10 +465,13 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
} }
void _shareQuip(Quip quip) { void _shareQuip(Quip quip) {
final url = AppRoutes.getPostUrl(quip.id); final url = AppRoutes.getQuipUrl(quip.id);
Share.share(url); final text = '${quip.caption}\n\n$url\n\n— @${quip.username} on Sojorn';
Share.share(text);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_error != null) { if (_error != null) {

View file

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

View file

@ -200,13 +200,18 @@ class AuthService {
Future<Map<String, dynamic>> signInWithGoBackend({ Future<Map<String, dynamic>> signInWithGoBackend({
required String email, required String email,
required String password, required String password,
required String turnstileToken,
}) async { }) async {
try { try {
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login'); final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login');
final response = await http.post( final response = await http.post(
uri, uri,
headers: {'Content-Type': 'application/json'}, 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); final data = jsonDecode(response.body);
@ -253,6 +258,11 @@ class AuthService {
required String password, required String password,
required String handle, required String handle,
required String displayName, required String displayName,
required String turnstileToken,
required bool acceptTerms,
required bool acceptPrivacy,
bool emailNewsletter = false,
bool emailContact = false,
}) async { }) async {
try { try {
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register'); final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register');
@ -264,6 +274,11 @@ class AuthService {
'password': password, 'password': password,
'handle': handle, 'handle': handle,
'display_name': displayName, '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); return _initFuture = _doInitialize(userId);
} }
// DEBUG: Set to true to force new key generation on startup (fixing bad keys) // Key rotation is now handled via initiateKeyRecovery() when needed
static const bool _FORCE_KEY_ROTATION = false; // DO NOT add debug flags here - use resetAllKeys() method for intentional resets
Future<void> resetAllKeys() async { Future<void> resetAllKeys() async {
print('[E2EE] RESETTING ALL KEYS - fixing MAC errors'); print('[E2EE] RESETTING ALL KEYS - fixing MAC errors');
@ -202,11 +202,7 @@ class SimpleE2EEService {
Future<void> _doInitialize(String userId) async { Future<void> _doInitialize(String userId) async {
_initializedForUserId = userId; _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 // 1. Try Local Storage
try { try {
@ -640,7 +636,7 @@ class SimpleE2EEService {
final secretBox = SecretBox(ciphertextBytes, nonce: nonce, mac: Mac(macBytes)); final secretBox = SecretBox(ciphertextBytes, nonce: nonce, mac: Mac(macBytes));
final plaintextBytes = await _cipher.decrypt(secretBox, secretKey: SecretKey(rootSecret)); final plaintextBytes = await _cipher.decrypt(secretBox, secretKey: SecretKey(rootSecret));
final plaintext = utf8.decode(plaintextBytes); final plaintext = utf8.decode(plaintextBytes);
print('[DECRYPT] SUCCESS: Decrypted message: "$plaintext"'); // Decryption successful - plaintext not logged for security
return plaintext; return plaintext;
} catch (e) { } catch (e) {
print('[DECRYPT] Failed: $e'); print('[DECRYPT] Failed: $e');

View file

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

View file

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../models/post.dart'; import '../../models/post.dart';
import '../../routes/app_routes.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../media/signed_media_image.dart'; import '../media/signed_media_image.dart';
import 'post_view_mode.dart'; import 'post_view_mode.dart';
@ -14,62 +17,74 @@ class PostMedia extends StatelessWidget {
final Post? post; final Post? post;
final Widget? child; final Widget? child;
final PostViewMode mode; final PostViewMode mode;
final VoidCallback? onTap;
const PostMedia({ const PostMedia({
super.key, super.key,
this.post, this.post,
this.child, this.child,
this.mode = PostViewMode.feed, this.mode = PostViewMode.feed,
this.onTap,
}); });
/// Get image height based on view mode /// Get image height based on view mode
double get _imageHeight { double get _imageHeight {
switch (mode) { switch (mode) {
case PostViewMode.feed: case PostViewMode.feed:
return 300.0; return 450.0; // Taller for better resolution/ratio
case PostViewMode.detail: case PostViewMode.detail:
return 500.0; // Full height for detail view return 600.0;
case PostViewMode.compact: case PostViewMode.compact:
return 200.0; // Smaller for profile lists return 200.0;
} }
} }
@override @override
Widget build(BuildContext context) { 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( return Padding(
padding: const EdgeInsets.only(top: AppTheme.spacingSm), padding: const EdgeInsets.only(top: AppTheme.spacingSm),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ConstrainedBox( ClipRRect(
constraints: BoxConstraints(maxHeight: _imageHeight), borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: SizedBox( child: Container(
width: double.infinity, width: double.infinity,
child: SignedMediaImage( // For videos in feed mode, use a more vertical 4:5 aspect ratio
url: post!.imageUrl!, // For other modes or non-videos, use the constrained height logic
fit: BoxFit.cover, child: InkWell(
loadingBuilder: (context) => Container( onTap: isVideo
color: AppTheme.queenPink.withValues(alpha: 0.3), ? () {
child: const Center(child: CircularProgressIndicator()), final url = '${AppRoutes.quips}?postId=${post!.id}';
), print('[PostMedia] Navigating to quips: $url');
errorBuilder: (context, error, stackTrace) => Container( context.go(url);
color: Colors.red.withValues(alpha: 0.3), }
child: Center( : onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ child: (isVideo && mode == PostViewMode.feed)
const Icon(Icons.broken_image, ? AspectRatio(
size: 48, color: Colors.white), aspectRatio: 4 / 5,
const SizedBox(height: 8), child: _buildMediaContent(displayUrl, true),
Text('Error: $error', )
style: const TextStyle( : ConstrainedBox(
color: Colors.white, fontSize: 10)), 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 'package:flutter/material.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'post/post_actions.dart'; import 'post/post_actions.dart';
import 'post/post_body.dart'; import 'post/post_body.dart';
@ -195,18 +196,21 @@ class sojornPostCard extends StatelessWidget {
), ),
), ),
// Media (if available) - clickable for post detail // Media (if available) - clickable for post detail (or quip player if video)
if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[ if ((post.imageUrl != null && post.imageUrl!.isNotEmpty) ||
(post.thumbnailUrl != null && post.thumbnailUrl!.isNotEmpty) ||
(post.videoUrl != null && post.videoUrl!.isNotEmpty)) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
InkWell( PostMedia(
post: post,
mode: mode,
onTap: onTap, onTap: onTap,
child: PostMedia(
post: post,
mode: mode,
),
), ),
], ],
// Actions section - with padding // Actions section - with padding
const SizedBox(height: 16), const SizedBox(height: 16),
Padding( Padding(

View file

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

View file

@ -40,12 +40,17 @@ $defineArgs = @(
"--dart-define=API_BASE_URL=$($values['API_BASE_URL'])" "--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 "Starting Sojorn in development mode..." -ForegroundColor Green
Write-Host "" Write-Host ""
Push-Location $PSScriptRoot Push-Location $PSScriptRoot
try { try {
flutter run @defineArgs @Args flutter run @defineArgs @Args
} finally { }
finally {
Pop-Location 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>