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/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")
{

View file

@ -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)
}

View file

@ -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<String> _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<String> _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<void> loadSafeDomains() async {
try {
final data = await ApiService.instance.callGoApi(
'/safe-domains',
method: 'GET',
);
final domains = data['domains'] as List<dynamic>? ?? [];
_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<void> 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<void> refresh() async {
await loadSafeDomains();
}
/// Get all cached safe domains (for debugging)
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: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<String> _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;
}