From 0954c1e2a315d4bec9eb0bff36bf1c36f3245a29 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Fri, 6 Feb 2026 08:51:34 -0600 Subject: [PATCH] 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 --- deploy_all_functions.bak.ps1 | 68 ----- go-backend/cmd/inspect/main_draft.go | 23 -- go-backend/internal/config/config.go | 4 + go-backend/internal/handlers/auth_handler.go | 32 ++- .../internal/repository/user_repository.go | 6 +- go-backend/internal/services/email_service.go | 151 +++++++---- run_dev.ps1 | 8 +- run_web.ps1 | 2 +- run_web_chrome.ps1 | 2 +- run_windows.ps1 | 2 +- sojorn_app/assets/images/mplslarge.png | Bin 0 -> 18370 bytes sojorn_app/assets/images/mplsmedium.png | Bin 0 -> 10112 bytes sojorn_app/assets/images/mplssmall.png | Bin 0 -> 2903 bytes sojorn_app/lib/models/post.dart | 5 +- .../lib/providers/quip_upload_provider.dart | 2 + sojorn_app/lib/routes/app_routes.dart | 17 +- .../lib/screens/auth/sign_in_screen.dart | 71 ++++- .../lib/screens/auth/sign_up_screen.dart | 243 ++++++++++++++---- .../post/threaded_conversation_screen.dart | 18 +- .../screens/quips/feed/quips_feed_screen.dart | 59 ++++- sojorn_app/lib/services/api_service.dart | 34 ++- sojorn_app/lib/services/auth_service.dart | 17 +- .../lib/services/simple_e2ee_service.dart | 12 +- sojorn_app/lib/utils/security_utils.dart | 3 +- sojorn_app/lib/widgets/post/post_media.dart | 123 ++++++--- sojorn_app/lib/widgets/sojorn_post_card.dart | 18 +- sojorn_app/pubspec.yaml | 2 +- sojorn_app/run_dev.ps1 | 7 +- verified_fixed.html | 197 ++++++++++++++ 29 files changed, 842 insertions(+), 284 deletions(-) delete mode 100644 deploy_all_functions.bak.ps1 delete mode 100644 go-backend/cmd/inspect/main_draft.go create mode 100644 sojorn_app/assets/images/mplslarge.png create mode 100644 sojorn_app/assets/images/mplsmedium.png create mode 100644 sojorn_app/assets/images/mplssmall.png create mode 100644 verified_fixed.html diff --git a/deploy_all_functions.bak.ps1 b/deploy_all_functions.bak.ps1 deleted file mode 100644 index f153cbc..0000000 --- a/deploy_all_functions.bak.ps1 +++ /dev/null @@ -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 "" diff --git a/go-backend/cmd/inspect/main_draft.go b/go-backend/cmd/inspect/main_draft.go deleted file mode 100644 index 9b94e18..0000000 --- a/go-backend/cmd/inspect/main_draft.go +++ /dev/null @@ -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 - } -} diff --git a/go-backend/internal/config/config.go b/go-backend/internal/config/config.go index 0de8ca6..eee9b54 100644 --- a/go-backend/internal/config/config.go +++ b/go-backend/internal/config/config.go @@ -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"), } } diff --git a/go-backend/internal/handlers/auth_handler.go b/go-backend/internal/handlers/auth_handler.go index f2b643c..95addc5 100644 --- a/go-backend/internal/handlers/auth_handler.go +++ b/go-backend/internal/handlers/auth_handler.go @@ -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) { diff --git a/go-backend/internal/repository/user_repository.go b/go-backend/internal/repository/user_repository.go index 20effbd..4498813 100644 --- a/go-backend/internal/repository/user_repository.go +++ b/go-backend/internal/repository/user_repository.go @@ -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 diff --git a/go-backend/internal/services/email_service.go b/go-backend/internal/services/email_service.go index c5370ff..5c5d5e9 100644 --- a/go-backend/internal/services/email_service.go +++ b/go-backend/internal/services/email_service.go @@ -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(` - - - - - - -
-
-
- -
-
-

Welcome to Sojorn, %s

-

Thanks for signing up! To get started, please verify your email address by clicking the button below.

- Verify Email Address -

If the button doesn't work, copy and paste this link into your browser:

- %s -
- -
-
- - - `, 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 := ` +

Welcome to Sojorn — your vibrant new social space. We're thrilled to have you join our community!

+

To get started in the app, please verify your email address by clicking the button below:

+ ` + + footer := ` +
+

If the button doesn't work, copy and paste this link into your browser:

+ %s +
+ ` + 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(` -

Reset Password for %s

-

You requested a password reset. Click the link below to set a new password:

-

Reset Password

-

This link expires in 1 hour.

-

If you did not request this, please ignore this email.

- `, toName, resetURL) + title := "Password Reset" + header := "Reset your password" + content := fmt.Sprintf(` +

Hey %s,

+

You requested a password reset for your Sojorn account. Click the button below to set a new password:

+ `, toName) - return s.sendEmail(toEmail, toName, subject, body, "Reset your password: "+resetURL) + footer := ` +

This link expires in 1 hour. If you did not request this, you can safely ignore this email.

+ ` + + 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(` + + + + + + %s + + +
+
+ +
+ Sojorn +
%s
+
+ + +
+

%s

+
+ %s +
+ + + %s + + + %s +
+ + +
+

Ā© 2026 Sojorn by MPLS LLC. All rights reserved.

+
+ Website • + Privacy • + Terms +
+
+
+
+ + + `, title, title, header, content, buttonURL, buttonText, footer) +} diff --git a/run_dev.ps1 b/run_dev.ps1 index ffb9f93..706edad 100644 --- a/run_dev.ps1 +++ b/run_dev.ps1 @@ -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 } diff --git a/run_web.ps1 b/run_web.ps1 index 7bda38d..e0e4960 100644 --- a/run_web.ps1 +++ b/run_web.ps1 @@ -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])" diff --git a/run_web_chrome.ps1 b/run_web_chrome.ps1 index f71318e..9a31a5a 100644 --- a/run_web_chrome.ps1 +++ b/run_web_chrome.ps1 @@ -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])" diff --git a/run_windows.ps1 b/run_windows.ps1 index 492fd4b..4f655fa 100644 --- a/run_windows.ps1 +++ b/run_windows.ps1 @@ -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])" diff --git a/sojorn_app/assets/images/mplslarge.png b/sojorn_app/assets/images/mplslarge.png new file mode 100644 index 0000000000000000000000000000000000000000..7fa2d5df60d316f625cebfe8f11349b9d75e405e GIT binary patch literal 18370 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 DPChQ3 literal 0 HcmV?d00001 diff --git a/sojorn_app/assets/images/mplsmedium.png b/sojorn_app/assets/images/mplsmedium.png new file mode 100644 index 0000000000000000000000000000000000000000..d55e6c5f0621ad78867377d6cc94b33740383148 GIT binary patch literal 10112 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

q8Vf|q3r1knd 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 BWCQ>J literal 0 HcmV?d00001 diff --git a/sojorn_app/lib/models/post.dart b/sojorn_app/lib/models/post.dart index d3c4e76..8a54c22 100644 --- a/sojorn_app/lib/models/post.dart +++ b/sojorn_app/lib/models/post.dart @@ -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( diff --git a/sojorn_app/lib/providers/quip_upload_provider.dart b/sojorn_app/lib/providers/quip_upload_provider.dart index 3b243e6..3374f8c 100644 --- a/sojorn_app/lib/providers/quip_upload_provider.dart +++ b/sojorn_app/lib/providers/quip_upload_provider.dart @@ -87,9 +87,11 @@ class QuipUploadNotifier extends Notifier { 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 { diff --git a/sojorn_app/lib/routes/app_routes.dart b/sojorn_app/lib/routes/app_routes.dart index 5fa32b5..7c2837d 100644 --- a/sojorn_app/lib/routes/app_routes.dart +++ b/sojorn_app/lib/routes/app_routes.dart @@ -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( diff --git a/sojorn_app/lib/screens/auth/sign_in_screen.dart b/sojorn_app/lib/screens/auth/sign_in_screen.dart index ed51186..8b9600b 100644 --- a/sojorn_app/lib/screens/auth/sign_in_screen.dart +++ b/sojorn_app/lib/screens/auth/sign_in_screen.dart @@ -31,7 +31,13 @@ class _SignInScreenState extends ConsumerState { 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 { bool get _canUseBiometricLogin => _supportsBiometric && _hasStoredCredentials && - !_isBiometricAuthenticating; + !_isBiometricAuthenticating && + _turnstileToken != null; // Require Turnstile for biometric too Future _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 { 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 { 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 { 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 { }, ), 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 { ], sojornButton( label: 'Sign In', - onPressed: isSubmitting ? null : _signIn, + onPressed: (isSubmitting || _turnstileToken == null) ? null : _signIn, isLoading: isSubmitting, isFullWidth: true, variant: sojornButtonVariant.primary, diff --git a/sojorn_app/lib/screens/auth/sign_up_screen.dart b/sojorn_app/lib/screens/auth/sign_up_screen.dart index 885476f..3e3c4fa 100644 --- a/sojorn_app/lib/screens/auth/sign_up_screen.dart +++ b/sojorn_app/lib/screens/auth/sign_up_screen.dart @@ -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 { 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 { Future _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 { 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 { } } } - + 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 { 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 { 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 { 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 { 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 { 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 { 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 { ), 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, ), diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart index 0d4eb6b..60eb298 100644 --- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart +++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart @@ -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 _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); diff --git a/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart b/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart index 92bb03b..0fcd652 100644 --- a/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart +++ b/sojorn_app/lib/screens/quips/feed/quips_feed_screen.dart @@ -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 createState() => _QuipsFeedScreenState(); @@ -99,9 +101,15 @@ class _QuipsFeedScreenState extends ConsumerState 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 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 final posts = (data['posts'] as List? ?? []).whereType>(); - final items = posts + List 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); + 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 } 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) { diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index f8b960a..9db39fe 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -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 blockUser(String userId) async { - // Migrate to Go API + await _callGoApi( + '/users/$userId/block', + method: 'POST', + ); } Future unblockUser(String userId) async { - // Migrate to Go API + await _callGoApi( + '/users/$userId/block', + method: 'DELETE', + ); } Future 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> 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; diff --git a/sojorn_app/lib/services/auth_service.dart b/sojorn_app/lib/services/auth_service.dart index e6b8cce..8f6887a 100644 --- a/sojorn_app/lib/services/auth_service.dart +++ b/sojorn_app/lib/services/auth_service.dart @@ -200,13 +200,18 @@ class AuthService { Future> 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, }), ); diff --git a/sojorn_app/lib/services/simple_e2ee_service.dart b/sojorn_app/lib/services/simple_e2ee_service.dart index fba3c22..9a9a871 100644 --- a/sojorn_app/lib/services/simple_e2ee_service.dart +++ b/sojorn_app/lib/services/simple_e2ee_service.dart @@ -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 resetAllKeys() async { print('[E2EE] RESETTING ALL KEYS - fixing MAC errors'); @@ -202,11 +202,7 @@ class SimpleE2EEService { Future _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'); diff --git a/sojorn_app/lib/utils/security_utils.dart b/sojorn_app/lib/utils/security_utils.dart index 4ad7dec..63d4df1 100644 --- a/sojorn_app/lib/utils/security_utils.dart +++ b/sojorn_app/lib/utils/security_utils.dart @@ -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)); diff --git a/sojorn_app/lib/widgets/post/post_media.dart b/sojorn_app/lib/widgets/post/post_media.dart index 03833d4..19322dd 100644 --- a/sojorn_app/lib/widgets/post/post_media.dart +++ b/sojorn_app/lib/widgets/post/post_media.dart @@ -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, + ), + ), + ), + ], + ); + } } + diff --git a/sojorn_app/lib/widgets/sojorn_post_card.dart b/sojorn_app/lib/widgets/sojorn_post_card.dart index ad962dd..f376353 100644 --- a/sojorn_app/lib/widgets/sojorn_post_card.dart +++ b/sojorn_app/lib/widgets/sojorn_post_card.dart @@ -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( diff --git a/sojorn_app/pubspec.yaml b/sojorn_app/pubspec.yaml index 22f3204..629e8e6 100644 --- a/sojorn_app/pubspec.yaml +++ b/sojorn_app/pubspec.yaml @@ -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 diff --git a/sojorn_app/run_dev.ps1 b/sojorn_app/run_dev.ps1 index 758db56..fdb839c 100644 --- a/sojorn_app/run_dev.ps1 +++ b/sojorn_app/run_dev.ps1 @@ -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 } diff --git a/verified_fixed.html b/verified_fixed.html new file mode 100644 index 0000000..214f315 --- /dev/null +++ b/verified_fixed.html @@ -0,0 +1,197 @@ + + + + + + + Email Verification - Sojorn + + + + + +

+
+ + + + +
+
+ + + +
+

Verification Required

+

We couldn't confirm your verification status. Please use the link sent to your email address.

+ Return to App +
+
+ + + + +