refactor: remove hardcoded safe domain lists, fetch from backend API instead
This commit is contained in:
parent
8b4198e6f0
commit
43199e52bc
|
|
@ -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")
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue