refactor: remove hardcoded safe domain lists, fetch from backend API instead

This commit is contained in:
Patrick Britton 2026-02-08 13:57:38 -06:00
parent 8b4198e6f0
commit 43199e52bc
4 changed files with 103 additions and 112 deletions

View file

@ -370,6 +370,10 @@ func main() {
authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice) authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice)
authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices) 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 Lifecycle routes
account := authorized.Group("/account") account := authorized.Group("/account")
{ {

View file

@ -1186,3 +1186,32 @@ func (h *PostHandler) ToggleReaction(c *gin.Context) {
"my_reactions": myReactions, "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)
}

View file

@ -1,102 +1,85 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../services/api_service.dart';
import '../widgets/safety_redirect_sheet.dart'; import '../widgets/safety_redirect_sheet.dart';
/// External Link Traffic Controller /// External Link Traffic Controller
/// ///
/// Provides safe URL routing with a domain whitelist. /// Provides safe URL routing using the backend safe_domains table.
/// Only domains in the whitelist are allowed without safety warnings. /// Domains in the approved list open without safety warnings.
/// All other domains will show a confirmation sheet before opening. /// All other domains show a confirmation sheet before opening.
/// ///
/// This is a "whitelist-only" approach: everything not explicitly trusted /// The safe domains list is fetched from the backend API and cached locally.
/// requires user confirmation.
class ExternalLinkController { class ExternalLinkController {
/// Whitelist of safe domains that open without any warning /// Cached safe domains fetched from the backend
/// These are trusted sources with established editorial standards static List<String> _safeDomains = [];
static const List<String> _whitelist = [ static bool _loaded = false;
// sojorn & Core Platforms static DateTime? _lastFetched;
'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',
// Established Progressive/Left Sources /// Fetch safe domains from the backend API and cache them.
'msnow.com', /// Called once on app startup or when needed.
'huffpost.com', static Future<void> loadSafeDomains() async {
'jacobin.com', try {
'wsws.org', final data = await ApiService.instance.callGoApi(
'counterpunch.org', '/safe-domains',
'truthout.org', method: 'GET',
'commondreams.org', );
'theintercept.com', final domains = data['domains'] as List<dynamic>? ?? [];
'leftvoice.org', _safeDomains = domains
'liberationnews.org', .map((d) => (d['domain'] as String? ?? '').toLowerCase())
'inthesetimes.com', .where((d) => d.isNotEmpty)
'monthlyreview.org', .toList();
'currentaffairs.org', _loaded = true;
'alternet.org', _lastFetched = DateTime.now();
'rawstory.com', if (kDebugMode) {
'therealnews.com', print('[SafeDomains] Loaded ${_safeDomains.length} safe domains from backend');
'popularresistance.org', }
'blackagendareport.com', } catch (e) {
'socialistworker.org', if (kDebugMode) {
'peoplesworld.org', print('[SafeDomains] Failed to fetch safe domains: $e');
'marxists.org', }
'roarmag.org', // Keep any previously cached domains
'versobooks.com', }
'tribunemag.co.uk', }
'novaramedia.com',
'opendemocracy.net',
'redpepper.org.uk',
'thenation.com',
'dissentmagazine.org',
'democracynow.org',
'fifthestate.org',
'crimethinc.com',
];
/// 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 /// [context] - BuildContext for showing dialogs/sheets
/// [url] - The URL to open /// [url] - The URL to open
/// ///
/// Flow: /// Flow:
/// 1. Parse the URL and extract the host (domain) /// 1. Ensure safe domains are loaded (lazy load if needed)
/// 2. Check if host ends with any whitelisted domain (case-insensitive) /// 2. Parse the URL and extract the host (domain)
/// 3. If whitelisted: launch immediately /// 3. Check if host matches any safe domain (suffix match)
/// 4. If not whitelisted: show SafetyRedirectSheet for user confirmation /// 4. If safe: launch immediately
/// 5. If not safe: show SafetyRedirectSheet for user confirmation
static Future<void> handleUrl(BuildContext context, String url) async { static Future<void> handleUrl(BuildContext context, String url) async {
if (url.trim().isEmpty) return; if (url.trim().isEmpty) return;
final Uri? uri = Uri.tryParse(url); final Uri? uri = Uri.tryParse(url);
if (uri == null) return; 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(); final String host = uri.host.toLowerCase();
// Check if domain is in whitelist if (_isSafe(host)) {
if (_isWhitelisted(host)) {
await _launchUrl(context, uri); await _launchUrl(context, uri);
} else { } else {
_showSafetyRedirectSheet(context, uri); _showSafetyRedirectSheet(context, uri);
} }
} }
/// Check if the domain is in the whitelist /// Check if the domain matches any safe domain (suffix match).
static bool _isWhitelisted(String host) { /// e.g., "news.bbc.co.uk" matches "bbc.co.uk"
return _whitelist.any((domain) => host.endsWith(domain)); static bool _isSafe(String host) {
return _safeDomains.any((domain) =>
host == domain || host.endsWith('.$domain'));
} }
/// Launch URL using url_launcher /// 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) { static void _showSafetyRedirectSheet(BuildContext context, Uri uri) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@ -138,21 +121,18 @@ class ExternalLinkController {
); );
} }
/// Add a domain to the whitelist at runtime /// Check if a domain is currently in the safe list
static void addToWhitelist(String domain) {
final normalizedDomain = domain.toLowerCase();
if (!_whitelist.contains(normalizedDomain)) {
_whitelist.add(normalizedDomain);
}
}
/// Check if a domain is currently whitelisted
static bool isWhitelisted(String domain) { 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<void> refresh() async {
await loadSafeDomains();
}
/// Get all cached safe domains (for debugging)
static List<String> getWhitelist() { static List<String> getWhitelist() {
return List.unmodifiable(_whitelist); return List.unmodifiable(_safeDomains);
} }
} }

View file

@ -1,41 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../theme/app_theme.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 { class UrlLauncherHelper {
// List of known safe domains /// Check if a URL is from a known safe domain.
static const List<String> _safeDomains = [ /// Delegates to [ExternalLinkController] which manages the backend-synced list.
'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
static bool isKnownSafeDomain(String url) { static bool isKnownSafeDomain(String url) {
try { try {
final uri = Uri.parse(url.startsWith('http') ? url : 'https://$url'); final uri = Uri.parse(url.startsWith('http') ? url : 'https://$url');
final host = uri.host.toLowerCase(); final host = uri.host.toLowerCase();
return ExternalLinkController.isWhitelisted(host);
return _safeDomains.any((domain) =>
host == domain || host.endsWith('.$domain')
);
} catch (e) { } catch (e) {
return false; return false;
} }