feat: safe domains management - admin UI, CRUD endpoints, URL safety checker, seeded domains

This commit is contained in:
Patrick Britton 2026-02-08 13:47:08 -06:00
parent e9e140df5e
commit 8b4198e6f0
7 changed files with 615 additions and 3 deletions

View file

@ -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<SafeDomain[]>([]);
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<any>(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 (
<AdminShell>
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="w-6 h-6 text-brand-500" /> Safe Links
</h1>
<p className="text-sm text-gray-500 mt-1">
Manage approved domains for link previews and external link warnings
</p>
</div>
<div className="flex items-center gap-2">
<button onClick={fetchDomains} className="p-2 rounded-lg border border-warm-300 hover:bg-warm-50 transition-colors">
<RefreshCw className="w-4 h-4 text-gray-600" />
</button>
<button onClick={() => setShowAdd(true)}
className="px-4 py-2 bg-brand-500 text-white rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors flex items-center gap-2">
<Plus className="w-4 h-4" /> Add Domain
</button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-warm-200 p-4">
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
<div className="text-xs text-gray-500">Total Domains</div>
</div>
<div className="bg-white rounded-xl border border-warm-200 p-4">
<div className="text-2xl font-bold text-green-600">{stats.approved}</div>
<div className="text-xs text-gray-500 flex items-center gap-1"><ShieldCheck className="w-3 h-3" /> Approved</div>
</div>
<div className="bg-white rounded-xl border border-warm-200 p-4">
<div className="text-2xl font-bold text-red-600">{stats.blocked}</div>
<div className="text-xs text-gray-500 flex items-center gap-1"><ShieldX className="w-3 h-3" /> Blocked</div>
</div>
<div className="bg-white rounded-xl border border-warm-200 p-4">
<div className="text-2xl font-bold text-blue-600">{stats.categories}</div>
<div className="text-xs text-gray-500">Categories</div>
</div>
</div>
{/* URL Safety Checker */}
<div className="bg-white rounded-xl border border-warm-200 p-4 mb-6">
<h2 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Search className="w-4 h-4" /> Check URL Safety
</h2>
<div className="flex items-center gap-2">
<input type="text" value={checkUrl} onChange={(e) => 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" />
<button onClick={handleCheck} disabled={checking || !checkUrl}
className="px-4 py-2 bg-gray-800 text-white rounded-lg text-sm font-medium hover:bg-gray-900 disabled:opacity-50 transition-colors">
{checking ? 'Checking...' : 'Check'}
</button>
</div>
{checkResult && (
<div className={`mt-3 p-3 rounded-lg text-sm flex items-center gap-2 ${
checkResult.safe ? 'bg-green-50 text-green-800 border border-green-200' :
checkResult.blocked ? 'bg-red-50 text-red-800 border border-red-200' :
'bg-amber-50 text-amber-800 border border-amber-200'
}`}>
{checkResult.safe ? <ShieldCheck className="w-4 h-4" /> :
checkResult.blocked ? <ShieldX className="w-4 h-4" /> :
<AlertCircle className="w-4 h-4" />}
<span className="font-medium">{checkResult.domain}</span>
<span className="font-bold uppercase">{checkResult.status}</span>
{checkResult.category && <span className="text-xs px-2 py-0.5 rounded bg-white/50">{checkResult.category}</span>}
</div>
)}
</div>
{/* Filters */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input type="text" value={filter} onChange={(e) => setFilter(e.target.value)}
placeholder="Filter domains..."
className="w-full pl-9 pr-3 py-2 border border-warm-300 rounded-lg text-sm" />
</div>
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border border-warm-300 rounded-lg text-sm bg-white">
<option value="">All categories</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
{/* Add Domain Form */}
{showAdd && <AddDomainForm onSave={() => { setShowAdd(false); fetchDomains(); }} onCancel={() => setShowAdd(false)} />}
{/* Domain List */}
{loading ? (
<div className="text-center py-12 text-gray-500">Loading...</div>
) : (
<div className="bg-white rounded-xl border border-warm-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-warm-50 border-b border-warm-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600">Domain</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Category</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Status</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Notes</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={5} className="text-center py-8 text-gray-400">No domains found</td></tr>
) : (
filtered.map((d) => (
<DomainRow key={d.id} domain={d} onDelete={() => handleDelete(d.id)} onRefresh={fetchDomains} />
))
)}
</tbody>
</table>
</div>
)}
</div>
</AdminShell>
);
}
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 (
<tr className="border-b border-warm-100 bg-brand-50/30">
<td className="px-4 py-2 font-mono text-xs">{d.domain}</td>
<td className="px-4 py-2">
<select value={category} onChange={(e) => setCategory(e.target.value)} className="px-2 py-1 border border-warm-300 rounded text-xs">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</td>
<td className="px-4 py-2">
<select value={isApproved ? 'true' : 'false'} onChange={(e) => setIsApproved(e.target.value === 'true')}
className="px-2 py-1 border border-warm-300 rounded text-xs">
<option value="true">Approved</option>
<option value="false">Blocked</option>
</select>
</td>
<td className="px-4 py-2">
<input type="text" value={notes} onChange={(e) => setNotes(e.target.value)}
className="w-full px-2 py-1 border border-warm-300 rounded text-xs" />
</td>
<td className="px-4 py-2 text-right">
<button onClick={handleSave} disabled={saving} className="text-xs px-2 py-1 bg-brand-500 text-white rounded mr-1">
{saving ? '...' : 'Save'}
</button>
<button onClick={() => setEditing(false)} className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700">Cancel</button>
</td>
</tr>
);
}
return (
<tr className="border-b border-warm-100 hover:bg-warm-50/50 transition-colors cursor-pointer" onClick={() => setEditing(true)}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Globe className="w-3.5 h-3.5 text-gray-400" />
<span className="font-mono text-xs font-medium">{d.domain}</span>
</div>
</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-warm-100 text-gray-600">{d.category}</span>
</td>
<td className="px-4 py-3">
{d.is_approved ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium flex items-center gap-1 w-fit">
<ShieldCheck className="w-3 h-3" /> Approved
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium flex items-center gap-1 w-fit">
<ShieldX className="w-3 h-3" /> Blocked
</span>
)}
</td>
<td className="px-4 py-3 text-xs text-gray-500 max-w-[200px] truncate">{d.notes || '—'}</td>
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<button onClick={onDelete} className="text-red-400 hover:text-red-600 transition-colors p-1">
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
);
}
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 (
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-warm-200 p-4 mb-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add Domain</h3>
<div className="grid grid-cols-4 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Domain</label>
<input type="text" value={domain} onChange={(e) => 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 />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Category</label>
<select value={category} onChange={(e) => setCategory(e.target.value)}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm bg-white">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
<select value={isApproved ? 'true' : 'false'} onChange={(e) => setIsApproved(e.target.value === 'true')}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm bg-white">
<option value="true"> Approved</option>
<option value="false">🚫 Blocked</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Notes</label>
<input type="text" value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder="Optional description" className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" />
</div>
</div>
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
<div className="flex items-center gap-2 mt-3">
<button type="submit" disabled={saving}
className="px-4 py-2 bg-brand-500 text-white rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-50">
{saving ? 'Saving...' : 'Add Domain'}
</button>
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">Cancel</button>
</div>
</form>
);
}

View file

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

View file

@ -464,6 +464,23 @@ class ApiClient {
body: JSON.stringify(data),
});
}
// Safe Domains
async listSafeDomains(category?: string) {
const params = category ? `?category=${category}` : '';
return this.request<any>(`/api/v1/admin/safe-domains${params}`);
}
async upsertSafeDomain(data: { domain: string; category: string; is_approved: boolean; notes: string }) {
return this.request<any>('/api/v1/admin/safe-domains', { method: 'POST', body: JSON.stringify(data) });
}
async deleteSafeDomain(id: string) {
return this.request<any>(`/api/v1/admin/safe-domains/${id}`, { method: 'DELETE' });
}
async checkURLSafety(url: string) {
return this.request<any>(`/api/v1/admin/safe-domains/check?url=${encodeURIComponent(url)}`);
}
}
export const api = new ApiClient();

View file

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

View file

@ -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;

View file

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

View file

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