diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 932c00e..d9577ad 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -370,6 +370,10 @@ func main() { authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice) authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices) + // Safe domains (for external link warnings in app) + authorized.GET("/safe-domains", postHandler.GetSafeDomains) + authorized.GET("/safe-domains/check", postHandler.CheckURLSafety) + // Account Lifecycle routes account := authorized.Group("/account") { diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go index 1f1ad19..230b14a 100644 --- a/go-backend/internal/handlers/post_handler.go +++ b/go-backend/internal/handlers/post_handler.go @@ -1186,3 +1186,32 @@ func (h *PostHandler) ToggleReaction(c *gin.Context) { "my_reactions": myReactions, }) } + +// GetSafeDomains returns the list of approved safe domains for the Flutter app. +func (h *PostHandler) GetSafeDomains(c *gin.Context) { + if h.linkPreviewService == nil { + c.JSON(http.StatusOK, gin.H{"domains": []string{}}) + return + } + domains, err := h.linkPreviewService.ListSafeDomains(c.Request.Context(), "", true) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"domains": domains}) +} + +// CheckURLSafety checks if a URL is from a safe domain. +func (h *PostHandler) CheckURLSafety(c *gin.Context) { + urlStr := c.Query("url") + if urlStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url parameter required"}) + return + } + if h.linkPreviewService == nil { + c.JSON(http.StatusOK, gin.H{"safe": false, "status": "unknown"}) + return + } + result := h.linkPreviewService.CheckURLSafety(c.Request.Context(), urlStr) + c.JSON(http.StatusOK, result) +} diff --git a/sojorn_app/lib/utils/external_link_controller.dart b/sojorn_app/lib/utils/external_link_controller.dart index 02da703..7cccb59 100644 --- a/sojorn_app/lib/utils/external_link_controller.dart +++ b/sojorn_app/lib/utils/external_link_controller.dart @@ -1,102 +1,85 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../services/api_service.dart'; import '../widgets/safety_redirect_sheet.dart'; /// External Link Traffic Controller /// -/// Provides safe URL routing with a domain whitelist. -/// Only domains in the whitelist are allowed without safety warnings. -/// All other domains will show a confirmation sheet before opening. +/// Provides safe URL routing using the backend safe_domains table. +/// Domains in the approved list open without safety warnings. +/// All other domains show a confirmation sheet before opening. /// -/// This is a "whitelist-only" approach: everything not explicitly trusted -/// requires user confirmation. +/// The safe domains list is fetched from the backend API and cached locally. class ExternalLinkController { - /// Whitelist of safe domains that open without any warning - /// These are trusted sources with established editorial standards - static const List _whitelist = [ - // sojorn & Core Platforms - 'sojorn.com', - 'youtube.com', - 'youtu.be', - 'wikipedia.org', - 'wikimedia.org', - 'github.com', - 'instagram.com', - 'linkedin.com', - 'medium.com', - 'reddit.com', - 'vimeo.com', - 'spotify.com', - 'apple.com', - 'google.com', - 'maps.google.com', - 'openstreetmap.org', + /// Cached safe domains fetched from the backend + static List _safeDomains = []; + static bool _loaded = false; + static DateTime? _lastFetched; - // Established Progressive/Left Sources - 'msnow.com', - 'huffpost.com', - 'jacobin.com', - 'wsws.org', - 'counterpunch.org', - 'truthout.org', - 'commondreams.org', - 'theintercept.com', - 'leftvoice.org', - 'liberationnews.org', - 'inthesetimes.com', - 'monthlyreview.org', - 'currentaffairs.org', - 'alternet.org', - 'rawstory.com', - 'therealnews.com', - 'popularresistance.org', - 'blackagendareport.com', - 'socialistworker.org', - 'peoplesworld.org', - 'marxists.org', - 'roarmag.org', - 'versobooks.com', - 'tribunemag.co.uk', - 'novaramedia.com', - 'opendemocracy.net', - 'redpepper.org.uk', - 'thenation.com', - 'dissentmagazine.org', - 'democracynow.org', - 'fifthestate.org', - 'crimethinc.com', - ]; + /// Fetch safe domains from the backend API and cache them. + /// Called once on app startup or when needed. + static Future loadSafeDomains() async { + try { + final data = await ApiService.instance.callGoApi( + '/safe-domains', + method: 'GET', + ); + final domains = data['domains'] as List? ?? []; + _safeDomains = domains + .map((d) => (d['domain'] as String? ?? '').toLowerCase()) + .where((d) => d.isNotEmpty) + .toList(); + _loaded = true; + _lastFetched = DateTime.now(); + if (kDebugMode) { + print('[SafeDomains] Loaded ${_safeDomains.length} safe domains from backend'); + } + } catch (e) { + if (kDebugMode) { + print('[SafeDomains] Failed to fetch safe domains: $e'); + } + // Keep any previously cached domains + } + } - /// Handles URL routing with whitelist safety checks. + /// Handles URL routing with safety checks against the backend safe_domains list. /// /// [context] - BuildContext for showing dialogs/sheets /// [url] - The URL to open /// /// Flow: - /// 1. Parse the URL and extract the host (domain) - /// 2. Check if host ends with any whitelisted domain (case-insensitive) - /// 3. If whitelisted: launch immediately - /// 4. If not whitelisted: show SafetyRedirectSheet for user confirmation + /// 1. Ensure safe domains are loaded (lazy load if needed) + /// 2. Parse the URL and extract the host (domain) + /// 3. Check if host matches any safe domain (suffix match) + /// 4. If safe: launch immediately + /// 5. If not safe: show SafetyRedirectSheet for user confirmation static Future handleUrl(BuildContext context, String url) async { if (url.trim().isEmpty) return; final Uri? uri = Uri.tryParse(url); if (uri == null) return; - // Extract domain from URL + // Lazy load safe domains if not yet loaded or stale (> 1 hour) + if (!_loaded || _lastFetched == null || + DateTime.now().difference(_lastFetched!).inHours >= 1) { + await loadSafeDomains(); + } + final String host = uri.host.toLowerCase(); - // Check if domain is in whitelist - if (_isWhitelisted(host)) { + if (_isSafe(host)) { await _launchUrl(context, uri); } else { _showSafetyRedirectSheet(context, uri); } } - /// Check if the domain is in the whitelist - static bool _isWhitelisted(String host) { - return _whitelist.any((domain) => host.endsWith(domain)); + /// Check if the domain matches any safe domain (suffix match). + /// e.g., "news.bbc.co.uk" matches "bbc.co.uk" + static bool _isSafe(String host) { + return _safeDomains.any((domain) => + host == domain || host.endsWith('.$domain')); } /// Launch URL using url_launcher @@ -115,7 +98,7 @@ class ExternalLinkController { } } - /// Show the Safety Redirect Sheet for non-whitelisted domains + /// Show the Safety Redirect Sheet for unknown domains static void _showSafetyRedirectSheet(BuildContext context, Uri uri) { showModalBottomSheet( context: context, @@ -138,21 +121,18 @@ class ExternalLinkController { ); } - /// Add a domain to the whitelist at runtime - static void addToWhitelist(String domain) { - final normalizedDomain = domain.toLowerCase(); - if (!_whitelist.contains(normalizedDomain)) { - _whitelist.add(normalizedDomain); - } - } - - /// Check if a domain is currently whitelisted + /// Check if a domain is currently in the safe list static bool isWhitelisted(String domain) { - return _isWhitelisted(domain.toLowerCase()); + return _isSafe(domain.toLowerCase()); } - /// Get all whitelisted domains (for debugging/admin purposes) + /// Force reload of safe domains from backend + static Future refresh() async { + await loadSafeDomains(); + } + + /// Get all cached safe domains (for debugging) static List getWhitelist() { - return List.unmodifiable(_whitelist); + return List.unmodifiable(_safeDomains); } } diff --git a/sojorn_app/lib/utils/url_launcher_helper.dart b/sojorn_app/lib/utils/url_launcher_helper.dart index fabb03c..9f35502 100644 --- a/sojorn_app/lib/utils/url_launcher_helper.dart +++ b/sojorn_app/lib/utils/url_launcher_helper.dart @@ -1,41 +1,19 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import '../theme/app_theme.dart'; +import 'external_link_controller.dart'; -/// Helper class for safely launching URLs with user warnings for unknown sites +/// Helper class for safely launching URLs with user warnings for unknown sites. +/// +/// Uses [ExternalLinkController] for the safe domains list (fetched from backend). class UrlLauncherHelper { - // List of known safe domains - static const List _safeDomains = [ - 'mp.ls', 'www.mp.ls', 'patrick.mp.ls' - 'sojorn.net', 'www.sojorn.net' - 'youtube.com', 'www.youtube.com', 'youtu.be', - 'instagram.com', 'www.instagram.com', - 'twitter.com', 'www.twitter.com', 'x.com', 'www.x.com', - 'facebook.com', 'www.facebook.com', - 'tiktok.com', 'www.tiktok.com', - 'linkedin.com', 'www.linkedin.com', - 'github.com', 'www.github.com', - 'twitch.tv', 'www.twitch.tv', - 'reddit.com', 'www.reddit.com', - 'medium.com', 'www.medium.com', - 'substack.com', - 'patreon.com', 'www.patreon.com', - 'discord.com', 'www.discord.com', 'discord.gg', - 'spotify.com', 'www.spotify.com', - 'pinterest.com', 'www.pinterest.com', - 'snapchat.com', 'www.snapchat.com', - 'telegram.org', 'www.telegram.org', 't.me', - ]; - - /// Check if a URL is from a known safe domain + /// Check if a URL is from a known safe domain. + /// Delegates to [ExternalLinkController] which manages the backend-synced list. static bool isKnownSafeDomain(String url) { try { final uri = Uri.parse(url.startsWith('http') ? url : 'https://$url'); final host = uri.host.toLowerCase(); - - return _safeDomains.any((domain) => - host == domain || host.endsWith('.$domain') - ); + return ExternalLinkController.isWhitelisted(host); } catch (e) { return false; }