diff --git a/admin/src/app/safe-links/page.tsx b/admin/src/app/safe-links/page.tsx new file mode 100644 index 0000000..bde7dd1 --- /dev/null +++ b/admin/src/app/safe-links/page.tsx @@ -0,0 +1,335 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { api } from '@/lib/api'; +import AdminShell from '@/components/AdminShell'; +import { Shield, ShieldCheck, ShieldX, Plus, Trash2, Search, Globe, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react'; + +interface SafeDomain { + id: string; + domain: string; + category: string; + is_approved: boolean; + notes: string | null; + created_at: string; + updated_at: string; +} + +const CATEGORIES = ['general', 'news', 'social', 'tech', 'education', 'government', 'internal']; + +export default function SafeLinksPage() { + const [domains, setDomains] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(''); + const [categoryFilter, setCategoryFilter] = useState(''); + const [showAdd, setShowAdd] = useState(false); + const [checkUrl, setCheckUrl] = useState(''); + const [checkResult, setCheckResult] = useState(null); + const [checking, setChecking] = useState(false); + + const fetchDomains = async () => { + setLoading(true); + try { + const data = await api.listSafeDomains(categoryFilter || undefined); + setDomains(data.domains || []); + } catch { } + setLoading(false); + }; + + useEffect(() => { fetchDomains(); }, [categoryFilter]); + + const filtered = filter + ? domains.filter(d => d.domain.includes(filter.toLowerCase()) || (d.notes || '').toLowerCase().includes(filter.toLowerCase())) + : domains; + + const approved = filtered.filter(d => d.is_approved); + const blocked = filtered.filter(d => !d.is_approved); + + const handleDelete = async (id: string) => { + if (!confirm('Remove this domain?')) return; + await api.deleteSafeDomain(id); + fetchDomains(); + }; + + const handleCheck = async () => { + if (!checkUrl) return; + setChecking(true); + try { + const result = await api.checkURLSafety(checkUrl); + setCheckResult(result); + } catch { setCheckResult({ error: 'Failed to check' }); } + setChecking(false); + }; + + const stats = { + total: domains.length, + approved: domains.filter(d => d.is_approved).length, + blocked: domains.filter(d => !d.is_approved).length, + categories: Array.from(new Set(domains.map(d => d.category))).length, + }; + + return ( + +
+ {/* Header */} +
+
+

+ Safe Links +

+

+ Manage approved domains for link previews and external link warnings +

+
+
+ + +
+
+ + {/* Stats */} +
+
+
{stats.total}
+
Total Domains
+
+
+
{stats.approved}
+
Approved
+
+
+
{stats.blocked}
+
Blocked
+
+
+
{stats.categories}
+
Categories
+
+
+ + {/* URL Safety Checker */} +
+

+ Check URL Safety +

+
+ setCheckUrl(e.target.value)} + placeholder="https://example.com/article" + onKeyDown={(e) => e.key === 'Enter' && handleCheck()} + className="flex-1 px-3 py-2 border border-warm-300 rounded-lg text-sm" /> + +
+ {checkResult && ( +
+ {checkResult.safe ? : + checkResult.blocked ? : + } + {checkResult.domain} — + {checkResult.status} + {checkResult.category && {checkResult.category}} +
+ )} +
+ + {/* Filters */} +
+
+ + setFilter(e.target.value)} + placeholder="Filter domains..." + className="w-full pl-9 pr-3 py-2 border border-warm-300 rounded-lg text-sm" /> +
+ +
+ + {/* Add Domain Form */} + {showAdd && { setShowAdd(false); fetchDomains(); }} onCancel={() => setShowAdd(false)} />} + + {/* Domain List */} + {loading ? ( +
Loading...
+ ) : ( +
+ + + + + + + + + + + + {filtered.length === 0 ? ( + + ) : ( + filtered.map((d) => ( + handleDelete(d.id)} onRefresh={fetchDomains} /> + )) + )} + +
DomainCategoryStatusNotesActions
No domains found
+
+ )} +
+
+ ); +} + +function DomainRow({ domain: d, onDelete, onRefresh }: { domain: SafeDomain; onDelete: () => void; onRefresh: () => void }) { + const [editing, setEditing] = useState(false); + const [category, setCategory] = useState(d.category); + const [isApproved, setIsApproved] = useState(d.is_approved); + const [notes, setNotes] = useState(d.notes || ''); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + setSaving(true); + await api.upsertSafeDomain({ domain: d.domain, category, is_approved: isApproved, notes }); + setSaving(false); + setEditing(false); + onRefresh(); + }; + + if (editing) { + return ( + + {d.domain} + + + + + + + + setNotes(e.target.value)} + className="w-full px-2 py-1 border border-warm-300 rounded text-xs" /> + + + + + + + ); + } + + return ( + setEditing(true)}> + +
+ + {d.domain} +
+ + + {d.category} + + + {d.is_approved ? ( + + Approved + + ) : ( + + Blocked + + )} + + {d.notes || '—'} + e.stopPropagation()}> + + + + ); +} + +function AddDomainForm({ onSave, onCancel }: { onSave: () => void; onCancel: () => void }) { + const [domain, setDomain] = useState(''); + const [category, setCategory] = useState('general'); + const [isApproved, setIsApproved] = useState(true); + const [notes, setNotes] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!domain.trim()) { setError('Domain is required'); return; } + setSaving(true); + setError(''); + try { + await api.upsertSafeDomain({ domain: domain.trim().toLowerCase(), category, is_approved: isApproved, notes }); + onSave(); + } catch (err: any) { + setError(err.message || 'Failed to save'); + } + setSaving(false); + }; + + return ( +
+

Add Domain

+
+
+ + setDomain(e.target.value)} + placeholder="example.com" className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm font-mono" autoFocus /> +
+
+ + +
+
+ + +
+
+ + setNotes(e.target.value)} + placeholder="Optional description" className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" /> +
+
+ {error &&

{error}

} +
+ + +
+
+ ); +} diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index 74a9e03..c766b27 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -5,7 +5,7 @@ import { usePathname } from 'next/navigation'; import { useAuth } from '@/lib/auth'; import { cn } from '@/lib/utils'; import { - LayoutDashboard, Users, FileText, Shield, Scale, Flag, + LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag, Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot, } from 'lucide-react'; import { useState } from 'react'; @@ -24,6 +24,7 @@ const navItems = [ { href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText }, { href: '/content-tools', label: 'Content Tools', icon: Wrench }, { href: '/official-accounts', label: 'Official Accounts', icon: Bot }, + { href: '/safe-links', label: 'Safe Links', icon: ShieldCheck }, { href: '/storage', label: 'Storage', icon: HardDrive }, { href: '/system', label: 'System Health', icon: Activity }, { href: '/settings', label: 'Settings', icon: Settings }, diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index 4225a63..f97f9a6 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -464,6 +464,23 @@ class ApiClient { body: JSON.stringify(data), }); } + // Safe Domains + async listSafeDomains(category?: string) { + const params = category ? `?category=${category}` : ''; + return this.request(`/api/v1/admin/safe-domains${params}`); + } + + async upsertSafeDomain(data: { domain: string; category: string; is_approved: boolean; notes: string }) { + return this.request('/api/v1/admin/safe-domains', { method: 'POST', body: JSON.stringify(data) }); + } + + async deleteSafeDomain(id: string) { + return this.request(`/api/v1/admin/safe-domains/${id}`, { method: 'DELETE' }); + } + + async checkURLSafety(url: string) { + return this.request(`/api/v1/admin/safe-domains/check?url=${encodeURIComponent(url)}`); + } } export const api = new ApiClient(); diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 25271b5..932c00e 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -166,7 +166,7 @@ func main() { officialAccountsService.StartScheduler() defer officialAccountsService.StopScheduler() - adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, officialAccountsService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain) + adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, officialAccountsService, linkPreviewService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain) accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg) @@ -493,6 +493,12 @@ func main() { admin.POST("/official-accounts/:id/preview", adminHandler.PreviewOfficialPost) admin.GET("/official-accounts/:id/articles", adminHandler.FetchNewsArticles) admin.GET("/official-accounts/:id/posted", adminHandler.GetPostedArticles) + + // Safe Domains Management + admin.GET("/safe-domains", adminHandler.ListSafeDomains) + admin.POST("/safe-domains", adminHandler.UpsertSafeDomain) + admin.DELETE("/safe-domains/:id", adminHandler.DeleteSafeDomain) + admin.GET("/safe-domains/check", adminHandler.CheckURLSafety) } // Public claim request endpoint (no auth) diff --git a/go-backend/internal/database/migrations/20260208000003_safe_domains.up.sql b/go-backend/internal/database/migrations/20260208000003_safe_domains.up.sql new file mode 100644 index 0000000..9c624f5 --- /dev/null +++ b/go-backend/internal/database/migrations/20260208000003_safe_domains.up.sql @@ -0,0 +1,53 @@ +-- Safe domains table: approved domains for link previews and external link warnings +CREATE TABLE IF NOT EXISTS safe_domains ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + domain TEXT NOT NULL UNIQUE, + category TEXT NOT NULL DEFAULT 'general', -- general, news, social, government, education, etc. + is_approved BOOLEAN NOT NULL DEFAULT true, -- true = safe, false = explicitly blocked + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_safe_domains_domain ON safe_domains (domain); +CREATE INDEX IF NOT EXISTS idx_safe_domains_approved ON safe_domains (is_approved); + +-- Seed with common safe domains +INSERT INTO safe_domains (domain, category, is_approved, notes) VALUES + -- News + ('npr.org', 'news', true, 'National Public Radio'), + ('apnews.com', 'news', true, 'Associated Press'), + ('bringmethenews.com', 'news', true, 'Bring Me The News - Minnesota'), + ('reuters.com', 'news', true, 'Reuters'), + ('bbc.com', 'news', true, 'BBC'), + ('bbc.co.uk', 'news', true, 'BBC UK'), + ('nytimes.com', 'news', true, 'New York Times'), + ('washingtonpost.com', 'news', true, 'Washington Post'), + ('theguardian.com', 'news', true, 'The Guardian'), + ('startribune.com', 'news', true, 'Star Tribune - Minnesota'), + ('mprnews.org', 'news', true, 'MPR News - Minnesota'), + ('kstp.com', 'news', true, 'KSTP - Minnesota'), + ('kare11.com', 'news', true, 'KARE 11 - Minnesota'), + ('fox9.com', 'news', true, 'Fox 9 - Minnesota'), + ('wcco.com', 'news', true, 'WCCO - Minnesota'), + -- Social / Tech + ('wikipedia.org', 'education', true, 'Wikipedia'), + ('github.com', 'tech', true, 'GitHub'), + ('youtube.com', 'social', true, 'YouTube'), + ('youtu.be', 'social', true, 'YouTube short links'), + ('vimeo.com', 'social', true, 'Vimeo'), + ('spotify.com', 'social', true, 'Spotify'), + ('open.spotify.com', 'social', true, 'Spotify Open'), + ('soundcloud.com', 'social', true, 'SoundCloud'), + ('twitch.tv', 'social', true, 'Twitch'), + ('reddit.com', 'social', true, 'Reddit'), + ('imgur.com', 'social', true, 'Imgur'), + -- Government + ('gov', 'government', true, 'US Government domains'), + ('state.mn.us', 'government', true, 'Minnesota State'), + ('minneapolismn.gov', 'government', true, 'City of Minneapolis'), + ('stpaul.gov', 'government', true, 'City of St. Paul'), + -- Sojorn + ('sojorn.net', 'internal', true, 'Sojorn'), + ('gosojorn.com', 'internal', true, 'Sojorn legacy') +ON CONFLICT (domain) DO NOTHING; diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index 0718fc3..bc5030f 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -27,6 +27,7 @@ type AdminHandler struct { emailService *services.EmailService openRouterService *services.OpenRouterService officialAccountsService *services.OfficialAccountsService + linkPreviewService *services.LinkPreviewService jwtSecret string turnstileSecret string s3Client *s3.Client @@ -36,7 +37,7 @@ type AdminHandler struct { vidDomain string } -func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, officialAccountsService *services.OfficialAccountsService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler { +func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler { return &AdminHandler{ pool: pool, moderationService: moderationService, @@ -44,6 +45,7 @@ func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationS emailService: emailService, openRouterService: openRouterService, officialAccountsService: officialAccountsService, + linkPreviewService: linkPreviewService, jwtSecret: jwtSecret, turnstileSecret: turnstileSecret, s3Client: s3Client, @@ -3119,3 +3121,65 @@ func (h *AdminHandler) GetPostedArticles(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"articles": articles}) } + +// ── Safe Domains Management ───────────────────────── + +func (h *AdminHandler) ListSafeDomains(c *gin.Context) { + category := c.Query("category") + approvedOnly := c.Query("approved_only") == "true" + + domains, err := h.linkPreviewService.ListSafeDomains(c.Request.Context(), category, approvedOnly) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"domains": domains}) +} + +func (h *AdminHandler) UpsertSafeDomain(c *gin.Context) { + var req struct { + Domain string `json:"domain" binding:"required"` + Category string `json:"category"` + IsApproved *bool `json:"is_approved"` + Notes string `json:"notes"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + cat := req.Category + if cat == "" { + cat = "general" + } + approved := true + if req.IsApproved != nil { + approved = *req.IsApproved + } + + domain, err := h.linkPreviewService.UpsertSafeDomain(c.Request.Context(), req.Domain, cat, approved, req.Notes) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"domain": domain}) +} + +func (h *AdminHandler) DeleteSafeDomain(c *gin.Context) { + id := c.Param("id") + if err := h.linkPreviewService.DeleteSafeDomain(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func (h *AdminHandler) CheckURLSafety(c *gin.Context) { + urlStr := c.Query("url") + if urlStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url parameter required"}) + return + } + result := h.linkPreviewService.CheckURLSafety(c.Request.Context(), urlStr) + c.JSON(http.StatusOK, result) +} diff --git a/go-backend/internal/services/link_preview_service.go b/go-backend/internal/services/link_preview_service.go index 693d669..0e7fa4c 100644 --- a/go-backend/internal/services/link_preview_service.go +++ b/go-backend/internal/services/link_preview_service.go @@ -320,3 +320,139 @@ func (s *LinkPreviewService) SaveLinkPreview(ctx context.Context, postID string, `, postID, lp.URL, lp.Title, lp.Description, lp.ImageURL, lp.SiteName) return err } + +// ── Safe Domains ───────────────────────────────────── + +// SafeDomain represents a row in the safe_domains table. +type SafeDomain struct { + ID string `json:"id"` + Domain string `json:"domain"` + Category string `json:"category"` + IsApproved bool `json:"is_approved"` + Notes *string `json:"notes"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ListSafeDomains returns all safe domains, optionally filtered. +func (s *LinkPreviewService) ListSafeDomains(ctx context.Context, category string, approvedOnly bool) ([]SafeDomain, error) { + query := `SELECT id, domain, category, is_approved, notes, created_at, updated_at FROM safe_domains WHERE 1=1` + args := []interface{}{} + idx := 1 + + if category != "" { + query += fmt.Sprintf(" AND category = $%d", idx) + args = append(args, category) + idx++ + } + if approvedOnly { + query += fmt.Sprintf(" AND is_approved = $%d", idx) + args = append(args, true) + idx++ + } + query += " ORDER BY category, domain" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var domains []SafeDomain + for rows.Next() { + var d SafeDomain + if err := rows.Scan(&d.ID, &d.Domain, &d.Category, &d.IsApproved, &d.Notes, &d.CreatedAt, &d.UpdatedAt); err != nil { + continue + } + domains = append(domains, d) + } + if domains == nil { + domains = []SafeDomain{} + } + return domains, nil +} + +// UpsertSafeDomain creates or updates a safe domain entry. +func (s *LinkPreviewService) UpsertSafeDomain(ctx context.Context, domain, category string, isApproved bool, notes string) (*SafeDomain, error) { + domain = strings.ToLower(strings.TrimSpace(domain)) + if domain == "" { + return nil, fmt.Errorf("domain is required") + } + + var d SafeDomain + err := s.pool.QueryRow(ctx, ` + INSERT INTO safe_domains (domain, category, is_approved, notes) + VALUES ($1, $2, $3, $4) + ON CONFLICT (domain) DO UPDATE SET + category = EXCLUDED.category, + is_approved = EXCLUDED.is_approved, + notes = EXCLUDED.notes, + updated_at = NOW() + RETURNING id, domain, category, is_approved, notes, created_at, updated_at + `, domain, category, isApproved, notes).Scan(&d.ID, &d.Domain, &d.Category, &d.IsApproved, &d.Notes, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + return &d, nil +} + +// DeleteSafeDomain removes a safe domain by ID. +func (s *LinkPreviewService) DeleteSafeDomain(ctx context.Context, id string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM safe_domains WHERE id = $1`, id) + return err +} + +// IsDomainSafe checks if a URL's domain (or any parent domain) is in the approved list. +// Returns: (isSafe bool, isBlocked bool, category string) +// isSafe=true means explicitly approved. isBlocked=true means explicitly blocked. +// Both false means unknown (not in the list). +func (s *LinkPreviewService) IsDomainSafe(ctx context.Context, rawURL string) (bool, bool, string) { + parsed, err := url.Parse(rawURL) + if err != nil { + return false, false, "" + } + host := strings.ToLower(parsed.Hostname()) + + // Check the domain and all parent domains (e.g., news.bbc.co.uk → bbc.co.uk → co.uk) + parts := strings.Split(host, ".") + for i := 0; i < len(parts)-1; i++ { + candidate := strings.Join(parts[i:], ".") + var isApproved bool + var category string + err := s.pool.QueryRow(ctx, + `SELECT is_approved, category FROM safe_domains WHERE domain = $1`, + candidate, + ).Scan(&isApproved, &category) + if err == nil { + return isApproved, !isApproved, category + } + } + return false, false, "" +} + +// CheckURLSafety returns a safety assessment for a URL (used by the Flutter app). +func (s *LinkPreviewService) CheckURLSafety(ctx context.Context, rawURL string) map[string]interface{} { + isSafe, isBlocked, category := s.IsDomainSafe(ctx, rawURL) + + parsed, _ := url.Parse(rawURL) + domain := "" + if parsed != nil { + domain = parsed.Hostname() + } + + status := "unknown" + if isSafe { + status = "safe" + } else if isBlocked { + status = "blocked" + } + + return map[string]interface{}{ + "url": rawURL, + "domain": domain, + "status": status, + "category": category, + "safe": isSafe, + "blocked": isBlocked, + } +}