diff --git a/web/next.config.ts b/web/next.config.ts --- a/web/next.config.ts +++ b/web/next.config.ts @@ -12,13 +12,19 @@ if (process.env.NODE_ENV === "production") { nextConfig.output = "export"; } else { - nextConfig.rewrites = async () => [ - { source: "/admin/:path*", destination: `${apiBase}/admin/:path*` }, - { source: "/xrpc/:path*", destination: `${apiBase}/xrpc/:path*` }, - { source: "/health", destination: `${apiBase}/health` }, - { source: "/aip/:path*", destination: `${aipBase}/:path*` }, - { source: "/config", destination: `${apiBase}/config` }, - ]; + nextConfig.rewrites = async () => ({ + // beforeFiles rewrites run before the trailingSlash redirect, + // preventing 308s on API fetch calls. + beforeFiles: [ + { source: "/admin/:path*", destination: `${apiBase}/admin/:path*` }, + { source: "/xrpc/:path*", destination: `${apiBase}/xrpc/:path*` }, + { source: "/health", destination: `${apiBase}/health` }, + { source: "/aip/:path*", destination: `${aipBase}/:path*` }, + { source: "/config", destination: `${apiBase}/config` }, + ], + afterFiles: [], + fallback: [], + }); } export default nextConfig; diff --git a/src/admin/records.rs b/src/admin/records.rs --- a/src/admin/records.rs +++ b/src/admin/records.rs @@ -1,6 +1,9 @@ +use std::collections::HashMap; + use axum::Json; use axum::extract::{Query, State}; use axum::http::StatusCode; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -23,10 +26,18 @@ } #[derive(Serialize)] +pub(super) struct RecordLabel { + pub src: String, + pub val: String, + pub cts: String, +} + +#[derive(Serialize)] pub(super) struct RecordEntry { pub uri: String, pub did: String, pub record: Value, + pub labels: Vec, } #[derive(Serialize)] @@ -61,10 +72,61 @@ .map_err(|e| AppError::Internal(format!("failed to list records: {e}")))?; let has_more = rows.len() as i64 > limit; - let records: Vec = rows + let visible_rows: Vec<(String, String, Value)> = + rows.into_iter().take(limit as usize).collect(); + + // Batch-query external labels for all visible URIs + let uris: Vec<&str> = visible_rows + .iter() + .map(|(uri, _, _)| uri.as_str()) + .collect(); + let label_rows: Vec<(String, String, String, DateTime)> = sqlx::query_as( + "SELECT uri, src, val, cts FROM labels WHERE uri = ANY($1) AND (exp IS NULL OR exp > NOW())", + ) + .bind(&uris) + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch labels: {e}")))?; + + // Group external labels by URI + let mut labels_by_uri: HashMap> = HashMap::new(); + for (uri, src, val, cts) in label_rows { + labels_by_uri.entry(uri).or_default().push(RecordLabel { + src, + val, + cts: cts.to_rfc3339(), + }); + } + + let records: Vec = visible_rows .into_iter() - .take(limit as usize) - .map(|(uri, did, record)| RecordEntry { uri, did, record }) + .map(|(uri, did, record)| { + let mut labels = labels_by_uri.remove(&uri).unwrap_or_default(); + + // Extract self-labels from record JSONB + if let Some(values) = record + .get("labels") + .and_then(|l| l.get("values")) + .and_then(|v| v.as_array()) + { + for entry in values { + if let Some(val) = entry.get("val").and_then(|v| v.as_str()) { + labels.push(RecordLabel { + src: did.clone(), + val: val.to_string(), + cts: String::new(), + }); + } + } + } + + RecordEntry { + uri, + did, + record, + labels, + } + }) .collect(); let cursor = if has_more { diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -11,6 +11,7 @@ IconLogout, IconKey, IconVariable, + IconTag, IconChevronRight, } from "@tabler/icons-react" import Image from "next/image" @@ -51,6 +52,7 @@ { title: "Users", url: "/dashboard/settings/users", icon: IconUsers, requiredPermissions: ["users:read"] }, { title: "ENV Variables", url: "/dashboard/settings/env-variables", icon: IconVariable, requiredPermissions: ["script-variables:read"] }, { title: "API Keys", url: "/dashboard/settings/api-keys", icon: IconKey, requiredPermissions: ["api-keys:read"] }, + { title: "Labelers", url: "/dashboard/settings/labelers", icon: IconTag, requiredPermissions: ["labelers:read"] }, ] as const export function AppSidebar({ diff --git a/web/src/components/label-badges.tsx b/web/src/components/label-badges.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/label-badges.tsx @@ -0,0 +1,72 @@ +import { Badge } from "@/components/ui/badge"; +import type { RecordLabel } from "@/types/records"; + +const CONTENT_WARNING_LABELS = new Set([ + "nudity", + "sexual", + "graphic-media", + "violence", + "gore", +]); + +const MODERATION_LABELS = new Set([ + "spam", + "impersonation", +]); + +function getLabelVariant( + val: string, + isSelfLabel: boolean, +): "destructive" | "outline" | "secondary" { + if (isSelfLabel) return "outline"; + if (CONTENT_WARNING_LABELS.has(val)) return "destructive"; + return "secondary"; +} + +function getLabelClassName(val: string, isSelfLabel: boolean): string { + if (isSelfLabel) return ""; + if (MODERATION_LABELS.has(val)) return "bg-amber-500 text-white border-amber-500"; + return ""; +} + +interface LabelBadgesProps { + labels: RecordLabel[]; + recordDid: string; +} + +export function LabelBadges({ labels, recordDid }: LabelBadgesProps) { + if (labels.length === 0) return null; + + // Group labels by source. + const grouped = new Map(); + for (const label of labels) { + const existing = grouped.get(label.src) ?? []; + existing.push(label); + grouped.set(label.src, existing); + } + + return ( +
+ {Array.from(grouped.entries()).map(([src, srcLabels], groupIdx) => ( +
+ {groupIdx > 0 && ( + · + )} + {srcLabels.map((label) => { + const isSelfLabel = label.src === recordDid; + return ( + + {label.val} + + ); + })} +
+ ))} +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -10,6 +10,7 @@ import type { AdminListRecordsResponse } from "@/types/records" import type { EventsListResponse } from "@/types/events" import type { ScriptVariableSummary } from "@/types/script-variables" +import type { LabelerSummary } from "@/types/labelers" export type { ApiKeySummary, CreateApiKeyResponse } from "@/types/api-keys" export type { CollectionStat, StatsResponse } from "@/types/stats" @@ -21,6 +22,8 @@ export type { AdminRecord, AdminListRecordsResponse } from "@/types/records" export type { EventLogEntry, EventsListResponse } from "@/types/events" export type { ScriptVariableSummary } from "@/types/script-variables" +export type { LabelerSummary } from "@/types/labelers" +export type { RecordLabel } from "@/types/records" // The DPoP proof for admin API calls must target AIP's userinfo URL, // because the backend forwards the proof to AIP for token validation. @@ -86,7 +89,9 @@ throw new ApiError(res.status, text) } if (res.status === 204) return null as T - return res.json() + const text = await res.text() + if (!text) return null as T + return JSON.parse(text) } // Stats @@ -319,6 +324,41 @@ getToken, { method: "DELETE" } ) +} + +// Labelers +export function getLabelers(getToken: () => Promise) { + return apiFetch("/admin/labelers", getToken) +} + +export function addLabeler( + getToken: () => Promise, + body: { did: string } +) { + return apiFetch("/admin/labelers", getToken, { + method: "POST", + body: JSON.stringify(body), + }) +} + +export function updateLabeler( + getToken: () => Promise, + did: string, + body: { status: string } +) { + return apiFetch(`/admin/labelers/${encodeURIComponent(did)}`, getToken, { + method: "PATCH", + body: JSON.stringify(body), + }) +} + +export function deleteLabeler( + getToken: () => Promise, + did: string +) { + return apiFetch(`/admin/labelers/${encodeURIComponent(did)}`, getToken, { + method: "DELETE", + }) } // Event Logs diff --git a/web/src/types/labelers.ts b/web/src/types/labelers.ts new file mode 100644 --- /dev/null +++ b/web/src/types/labelers.ts @@ -0,0 +1,7 @@ +export interface LabelerSummary { + did: string + status: string + cursor: number | null + created_at: string + updated_at: string +} diff --git a/web/src/types/records.ts b/web/src/types/records.ts --- a/web/src/types/records.ts +++ b/web/src/types/records.ts @@ -1,7 +1,14 @@ +export interface RecordLabel { + src: string + val: string + cts: string +} + export interface AdminRecord { uri: string did: string record: Record + labels: RecordLabel[] } export interface AdminListRecordsResponse { diff --git a/web/src/app/dashboard/records/page.tsx b/web/src/app/dashboard/records/page.tsx --- a/web/src/app/dashboard/records/page.tsx +++ b/web/src/app/dashboard/records/page.tsx @@ -39,6 +39,7 @@ DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { LabelBadges } from "@/components/label-badges"; import { SiteHeader } from "@/components/site-header"; import { Button } from "@/components/ui/button"; import { @@ -263,6 +264,19 @@ enableSorting: false, enableHiding: false, meta: { label: "Record Key" }, + }, + { + id: "labels", + accessorFn: (row) => row.labels, + header: "Labels", + enableSorting: false, + cell: ({ row }) => ( + + ), + meta: { label: "Labels" }, }, ]; diff --git a/web/src/app/dashboard/settings/labelers/page.tsx b/web/src/app/dashboard/settings/labelers/page.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/dashboard/settings/labelers/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Trash2, Pause, Play } from "lucide-react"; + +import { useAuth } from "@/lib/auth-context"; +import { useCurrentUser } from "@/hooks/use-current-user"; +import { + getLabelers, + addLabeler, + updateLabeler, + deleteLabeler, +} from "@/lib/api"; +import type { LabelerSummary } from "@/types/labelers"; +import { SiteHeader } from "@/components/site-header"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from "@/components/ui/responsive-dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export default function LabelersPage() { + const { getToken } = useAuth(); + const { hasPermission } = useCurrentUser(); + const [labelers, setLabelers] = useState([]); + const [handles, setHandles] = useState>({}); + const [error, setError] = useState(null); + const [deleteDid, setDeleteDid] = useState(null); + const [deleting, setDeleting] = useState(false); + + const load = useCallback(() => { + getLabelers(getToken) + .then(setLabelers) + .catch((e) => setError(e.message)); + }, [getToken]); + + useEffect(() => { + load(); + }, [load]); + + // Resolve DIDs to handles via PLC directory + useEffect(() => { + const newDids = labelers.map((l) => l.did).filter((did) => !(did in handles)); + if (newDids.length === 0) return; + for (const did of newDids) { + fetch(`https://plc.directory/${encodeURIComponent(did)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + const handle = data.alsoKnownAs + ?.find((aka: string) => aka.startsWith("at://")) + ?.replace("at://", ""); + if (handle) { + setHandles((prev) => ({ ...prev, [did]: handle })); + } + }) + .catch(() => {}); + } + }, [labelers, handles]); + + async function handleToggleStatus(labeler: LabelerSummary) { + try { + const newStatus = labeler.status === "active" ? "paused" : "active"; + await updateLabeler(getToken, labeler.did, { status: newStatus }); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + async function handleDelete(did: string) { + setDeleting(true); + try { + await deleteLabeler(getToken, did); + setDeleteDid(null); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setDeleting(false); + } + } + + return ( + <> + +
+ {error &&

{error}

} + +
+
+

Labeler Subscriptions

+

+ Manage external labeler services that provide content labels. +

+
+ {hasPermission("labelers:create") && ( + + )} +
+ +
+ + + + DID + Status + Cursor + Created + Updated + + + + + {labelers.length === 0 && ( + + + No labeler subscriptions yet. + + + )} + {labelers.map((l) => ( + + +
+ {handles[l.did] && ( + @{handles[l.did]} + )} + + {l.did} + +
+
+ + + {l.status} + + + + {l.cursor ?? "—"} + + + {new Date(l.created_at).toLocaleString()} + + + {new Date(l.updated_at).toLocaleString()} + + +
+ {hasPermission("labelers:create") && ( + + )} + {hasPermission("labelers:delete") && ( + + )} +
+
+
+ ))} +
+
+
+
+ + { + if (!open) setDeleteDid(null); + }} + > + + + Delete labeler? + + This will remove the labeler subscription and delete all labels it + has emitted. This action cannot be undone. + + + {deleteDid && ( + + {deleteDid} + + )} + + + + + + + + + + ); +} + +function AddLabelerDialog({ + getToken, + onSuccess, +}: { + getToken: () => Promise; + onSuccess: () => void; +}) { + const [did, setDid] = useState(""); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); + + async function handleAdd() { + setError(null); + try { + await addLabeler(getToken, { did }); + setDid(""); + setOpen(false); + onSuccess(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + return ( + { + setOpen(o); + if (o) { + setDid(""); + setError(null); + } + }} + > + + + + + + Add Labeler + + Subscribe to an external labeler service by entering its DID. + + +
+ {error &&

{error}

} +
+ + setDid(e.target.value)} + placeholder="did:plc:..." + className="font-mono" + /> +
+
+ + + + + + +
+
+ ); +}