feat: safe domains management - admin UI, CRUD endpoints, URL safety checker, seeded domains
This commit is contained in:
parent
e9e140df5e
commit
8b4198e6f0
335
admin/src/app/safe-links/page.tsx
Normal file
335
admin/src/app/safe-links/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import { usePathname } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
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,
|
Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
@ -24,6 +24,7 @@ const navItems = [
|
||||||
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
|
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
|
||||||
{ href: '/content-tools', label: 'Content Tools', icon: Wrench },
|
{ href: '/content-tools', label: 'Content Tools', icon: Wrench },
|
||||||
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
||||||
|
{ href: '/safe-links', label: 'Safe Links', icon: ShieldCheck },
|
||||||
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
||||||
{ href: '/system', label: 'System Health', icon: Activity },
|
{ href: '/system', label: 'System Health', icon: Activity },
|
||||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,23 @@ class ApiClient {
|
||||||
body: JSON.stringify(data),
|
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();
|
export const api = new ApiClient();
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ func main() {
|
||||||
officialAccountsService.StartScheduler()
|
officialAccountsService.StartScheduler()
|
||||||
defer officialAccountsService.StopScheduler()
|
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)
|
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
|
||||||
|
|
||||||
|
|
@ -493,6 +493,12 @@ func main() {
|
||||||
admin.POST("/official-accounts/:id/preview", adminHandler.PreviewOfficialPost)
|
admin.POST("/official-accounts/:id/preview", adminHandler.PreviewOfficialPost)
|
||||||
admin.GET("/official-accounts/:id/articles", adminHandler.FetchNewsArticles)
|
admin.GET("/official-accounts/:id/articles", adminHandler.FetchNewsArticles)
|
||||||
admin.GET("/official-accounts/:id/posted", adminHandler.GetPostedArticles)
|
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)
|
// Public claim request endpoint (no auth)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -27,6 +27,7 @@ type AdminHandler struct {
|
||||||
emailService *services.EmailService
|
emailService *services.EmailService
|
||||||
openRouterService *services.OpenRouterService
|
openRouterService *services.OpenRouterService
|
||||||
officialAccountsService *services.OfficialAccountsService
|
officialAccountsService *services.OfficialAccountsService
|
||||||
|
linkPreviewService *services.LinkPreviewService
|
||||||
jwtSecret string
|
jwtSecret string
|
||||||
turnstileSecret string
|
turnstileSecret string
|
||||||
s3Client *s3.Client
|
s3Client *s3.Client
|
||||||
|
|
@ -36,7 +37,7 @@ type AdminHandler struct {
|
||||||
vidDomain string
|
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{
|
return &AdminHandler{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
moderationService: moderationService,
|
moderationService: moderationService,
|
||||||
|
|
@ -44,6 +45,7 @@ func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationS
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
openRouterService: openRouterService,
|
openRouterService: openRouterService,
|
||||||
officialAccountsService: officialAccountsService,
|
officialAccountsService: officialAccountsService,
|
||||||
|
linkPreviewService: linkPreviewService,
|
||||||
jwtSecret: jwtSecret,
|
jwtSecret: jwtSecret,
|
||||||
turnstileSecret: turnstileSecret,
|
turnstileSecret: turnstileSecret,
|
||||||
s3Client: s3Client,
|
s3Client: s3Client,
|
||||||
|
|
@ -3119,3 +3121,65 @@ func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"articles": articles})
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -320,3 +320,139 @@ func (s *LinkPreviewService) SaveLinkPreview(ctx context.Context, postID string,
|
||||||
`, postID, lp.URL, lp.Title, lp.Description, lp.ImageURL, lp.SiteName)
|
`, postID, lp.URL, lp.Title, lp.Description, lp.ImageURL, lp.SiteName)
|
||||||
return err
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue