fix more db query issues, improve labeler usage

This commit is contained in:
Evan Jarrett
2026-05-02 22:59:32 -05:00
parent ab66028151
commit 7b4a2e22a2
6 changed files with 882 additions and 308 deletions
+30
View File
@@ -29,6 +29,36 @@ func GetRepositoryAnnotations(db DBTX, did, repository string) (map[string]strin
return annotations, rows.Err()
}
// GetRepositoryAnnotationsByDID retrieves all annotations for every
// repository owned by a DID, grouped as map[repository]map[key]value.
// Used by bulk-fetch paths to avoid issuing one query per repository.
func GetRepositoryAnnotationsByDID(db DBTX, did string) (map[string]map[string]string, error) {
rows, err := db.Query(`
SELECT repository, key, value
FROM repository_annotations
WHERE did = ?
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]map[string]string)
for rows.Next() {
var repo, key, value string
if err := rows.Scan(&repo, &key, &value); err != nil {
return nil, err
}
m, ok := out[repo]
if !ok {
m = make(map[string]string)
out[repo] = m
}
m[key] = value
}
return out, rows.Err()
}
// UpsertRepositoryAnnotations upserts annotations for a repository.
// Stale keys not present in the new map are deleted.
// Unchanged values are skipped to avoid unnecessary writes.
+157 -87
View File
@@ -241,10 +241,15 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID
// GetUserRepositories fetches all repositories for a user.
// viewerDID scopes results to repositories whose manifests live on holds the
// viewer can access (empty viewerDID = anonymous → public + self-service only).
//
// Implementation: one summary query for the accessible repository set, then
// four bulk queries (tags, manifests, annotations, repo_pages) all keyed by
// did. Results are grouped in Go and assembled per repo. Total: 5 queries
// regardless of how many repos the user owns.
func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, error) {
// Get repository summary.
// Both tags and manifests are filtered via join onto manifests.hold_endpoint
// so repositories where every row lives on an inaccessible hold drop out.
// Step 1: summary query. Both tags and manifests are filtered via join
// onto manifests.hold_endpoint so repositories where every row lives on
// an inaccessible hold drop out.
rows, err := db.Query(`
SELECT
repository,
@@ -264,97 +269,80 @@ func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, e
GROUP BY repository
ORDER BY last_push DESC
`, did, viewerDID, viewerDID, did, viewerDID, viewerDID)
if err != nil {
return nil, err
}
defer rows.Close()
var repos []Repository
type repoSummary struct {
Name string
TagCount int
ManifestCount int
LastPushStr string
}
var summaries []repoSummary
for rows.Next() {
var r Repository
var lastPushStr string
if err := rows.Scan(&r.Name, &r.TagCount, &r.ManifestCount, &lastPushStr); err != nil {
var s repoSummary
if err := rows.Scan(&s.Name, &s.TagCount, &s.ManifestCount, &s.LastPushStr); err != nil {
rows.Close()
return nil, err
}
summaries = append(summaries, s)
}
rows.Close()
// Parse the timestamp string into time.Time
if lastPushStr != "" {
// Try multiple timestamp formats
formats := []string{
time.RFC3339Nano, // 2006-01-02T15:04:05.999999999Z07:00
"2006-01-02 15:04:05.999999999-07:00", // SQLite with microseconds and timezone
"2006-01-02 15:04:05.999999999", // SQLite with microseconds
time.RFC3339, // 2006-01-02T15:04:05Z07:00
"2006-01-02 15:04:05", // SQLite default
}
if len(summaries) == 0 {
return nil, nil
}
for _, format := range formats {
if t, err := time.Parse(format, lastPushStr); err == nil {
r.LastPush = t
break
}
}
}
// Get tags for this repo
tagRows, err := db.Query(`
SELECT id, tag, digest, created_at
FROM tags
WHERE did = ? AND repository = ?
ORDER BY created_at DESC
`, did, r.Name)
if err != nil {
return nil, err
}
for tagRows.Next() {
var t Tag
t.DID = did
t.Repository = r.Name
if err := tagRows.Scan(&t.ID, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
tagRows.Close()
return nil, err
}
r.Tags = append(r.Tags, t)
}
tagRows.Close()
// Get manifests for this repo
manifestRows, err := db.Query(`
SELECT id, digest, hold_endpoint, schema_version, media_type,
config_digest, config_size, artifact_type, created_at
FROM manifests
WHERE did = ? AND repository = ?
ORDER BY created_at DESC
`, did, r.Name)
if err != nil {
return nil, err
}
for manifestRows.Next() {
var m Manifest
m.DID = did
m.Repository = r.Name
if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
&m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil {
manifestRows.Close()
return nil, err
}
r.Manifests = append(r.Manifests, m)
}
manifestRows.Close()
// Fetch repository-level annotations from annotations table
annotations, err := GetRepositoryAnnotations(db, did, r.Name)
if err != nil {
return nil, err
// Build the set of accessible repo names for filtering bulk-fetched rows
// against repos that the viewer can't see (rows for repos owned by `did`
// but stored on inaccessible holds).
accessible := make(map[string]bool, len(summaries))
for _, s := range summaries {
accessible[s.Name] = true
}
// Step 2: bulk-fetch tags for all repos owned by did, grouped by repo.
tagsByRepo, err := bulkTagsByRepo(db, did, accessible)
if err != nil {
return nil, err
}
// Step 3: bulk-fetch manifests, grouped by repo.
manifestsByRepo, err := bulkManifestsByRepo(db, did, accessible)
if err != nil {
return nil, err
}
// Step 4: bulk-fetch annotations, grouped by repo.
annotationsByRepo, err := GetRepositoryAnnotationsByDID(db, did)
if err != nil {
return nil, err
}
// Step 5: bulk-fetch repo pages (existing helper), keyed by repo.
pages, err := GetRepoPagesByDID(db, did)
if err != nil {
return nil, err
}
pagesByRepo := make(map[string]*RepoPage, len(pages))
for i := range pages {
pagesByRepo[pages[i].Repository] = &pages[i]
}
// Assemble results in summary order (preserves last_push DESC).
repos := make([]Repository, 0, len(summaries))
for _, s := range summaries {
r := Repository{
Name: s.Name,
TagCount: s.TagCount,
ManifestCount: s.ManifestCount,
LastPush: parseRepoTimestamp(s.LastPushStr),
Tags: tagsByRepo[s.Name],
Manifests: manifestsByRepo[s.Name],
}
annotations := annotationsByRepo[s.Name]
r.Title = annotations["org.opencontainers.image.title"]
r.Description = annotations["org.opencontainers.image.description"]
r.SourceURL = annotations["org.opencontainers.image.source"]
@@ -363,10 +351,9 @@ func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, e
r.IconURL = annotations["io.atcr.icon"]
r.ReadmeURL = annotations["io.atcr.readme"]
// Check for repo page avatar (overrides annotation icon)
repoPage, err := GetRepoPage(db, did, r.Name)
if err == nil && repoPage != nil && repoPage.AvatarCID != "" {
r.IconURL = BlobCDNURL(did, repoPage.AvatarCID)
// Repo page avatar overrides annotation icon when present.
if page, ok := pagesByRepo[s.Name]; ok && page.AvatarCID != "" {
r.IconURL = BlobCDNURL(did, page.AvatarCID)
}
repos = append(repos, r)
@@ -375,6 +362,89 @@ func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, e
return repos, nil
}
// bulkTagsByRepo fetches every tag owned by did and groups by repository,
// dropping repos not in the accessible set. Result preserves created_at DESC
// ordering within each repo.
func bulkTagsByRepo(db DBTX, did string, accessible map[string]bool) (map[string][]Tag, error) {
rows, err := db.Query(`
SELECT id, repository, tag, digest, created_at
FROM tags
WHERE did = ?
ORDER BY repository, created_at DESC
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string][]Tag)
for rows.Next() {
var t Tag
t.DID = did
if err := rows.Scan(&t.ID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
return nil, err
}
if !accessible[t.Repository] {
continue
}
out[t.Repository] = append(out[t.Repository], t)
}
return out, rows.Err()
}
// bulkManifestsByRepo fetches every manifest owned by did and groups by
// repository, dropping repos not in the accessible set. Result preserves
// created_at DESC ordering within each repo.
func bulkManifestsByRepo(db DBTX, did string, accessible map[string]bool) (map[string][]Manifest, error) {
rows, err := db.Query(`
SELECT id, repository, digest, hold_endpoint, schema_version, media_type,
config_digest, config_size, artifact_type, created_at
FROM manifests
WHERE did = ?
ORDER BY repository, created_at DESC
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string][]Manifest)
for rows.Next() {
var m Manifest
m.DID = did
if err := rows.Scan(&m.ID, &m.Repository, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
&m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil {
return nil, err
}
if !accessible[m.Repository] {
continue
}
out[m.Repository] = append(out[m.Repository], m)
}
return out, rows.Err()
}
// parseRepoTimestamp tolerates the several timestamp formats SQLite/libsql
// can return for MAX(created_at) depending on driver and schema history.
func parseRepoTimestamp(s string) time.Time {
if s == "" {
return time.Time{}
}
formats := []string{
time.RFC3339Nano, // 2006-01-02T15:04:05.999999999Z07:00
"2006-01-02 15:04:05.999999999-07:00", // SQLite with microseconds and timezone
"2006-01-02 15:04:05.999999999", // SQLite with microseconds
time.RFC3339, // 2006-01-02T15:04:05Z07:00
"2006-01-02 15:04:05", // SQLite default
}
for _, format := range formats {
if t, err := time.Parse(format, s); err == nil {
return t
}
}
return time.Time{}
}
// GetRepositoryMetadata retrieves metadata for a repository from annotations table
// Returns a map of annotation key -> value for easy access in templates and handlers
func GetRepositoryMetadata(db DBTX, did string, repository string) (map[string]string, error) {
+160
View File
@@ -1607,3 +1607,163 @@ func TestGetUserRepositories_HoldAccessFilter(t *testing.T) {
t.Errorf("crew viewer: expected both repos, got %d: %v", len(repos), repos)
}
}
// TestGetUserRepositories_BulkGrouping verifies that the bulk-fetch
// implementation correctly groups tags, manifests, annotations, and repo-page
// avatars per repository — and that ordering (last_push DESC for repos,
// created_at DESC for tags/manifests within a repo) is preserved.
//
// Regression guard for the previous N+1 implementation, which issued one
// query per repo and per relation.
func TestGetUserRepositories_BulkGrouping(t *testing.T) {
db, err := InitDB("file:TestGetUserRepositories_BulkGrouping?mode=memory&cache=shared", LibsqlConfig{})
if err != nil {
t.Fatalf("init db: %v", err)
}
defer db.Close()
user := &User{DID: "did:plc:owner", Handle: "owner.test", PDSEndpoint: "https://pds.example", LastSeen: time.Now()}
if err := UpsertUser(db, user); err != nil {
t.Fatalf("upsert user: %v", err)
}
if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
HoldDID: "did:web:hold.example", OwnerDID: "did:plc:holdowner", Public: true,
}); err != nil {
t.Fatalf("seed captain: %v", err)
}
now := time.Now().UTC().Truncate(time.Second)
mediaType := "application/vnd.oci.image.manifest.v1+json"
// repoA: two manifests (oldest then newer) and two tags. last_push = now+10s.
manifestA1, err := InsertManifest(db, &Manifest{
DID: user.DID, Repository: "repoA", Digest: "sha256:a1",
HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
CreatedAt: now,
})
if err != nil {
t.Fatalf("insert manifest a1: %v", err)
}
manifestA2, err := InsertManifest(db, &Manifest{
DID: user.DID, Repository: "repoA", Digest: "sha256:a2",
HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
CreatedAt: now.Add(5 * time.Second),
})
if err != nil {
t.Fatalf("insert manifest a2: %v", err)
}
if err := UpsertTag(db, &Tag{DID: user.DID, Repository: "repoA", Tag: "v1", Digest: "sha256:a1", CreatedAt: now.Add(8 * time.Second)}); err != nil {
t.Fatalf("upsert tag v1: %v", err)
}
if err := UpsertTag(db, &Tag{DID: user.DID, Repository: "repoA", Tag: "v2", Digest: "sha256:a2", CreatedAt: now.Add(10 * time.Second)}); err != nil {
t.Fatalf("upsert tag v2: %v", err)
}
// repoB: one manifest, one tag. last_push = now+1s (older than repoA → repoA sorts first).
if _, err := InsertManifest(db, &Manifest{
DID: user.DID, Repository: "repoB", Digest: "sha256:b1",
HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
CreatedAt: now.Add(1 * time.Second),
}); err != nil {
t.Fatalf("insert manifest b1: %v", err)
}
if err := UpsertTag(db, &Tag{DID: user.DID, Repository: "repoB", Tag: "latest", Digest: "sha256:b1", CreatedAt: now.Add(1 * time.Second)}); err != nil {
t.Fatalf("upsert tag b latest: %v", err)
}
// Annotations only on repoA, plus a repo-page avatar on repoB to exercise the icon override.
if err := UpsertRepositoryAnnotations(db, user.DID, "repoA", map[string]string{
"org.opencontainers.image.title": "Repo A Title",
"org.opencontainers.image.description": "alpha",
"io.atcr.icon": "https://example.com/a.png",
}); err != nil {
t.Fatalf("upsert annotations: %v", err)
}
if err := UpsertRepoPage(db, user.DID, "repoB", "", "bafyrepob", false, now, now); err != nil {
t.Fatalf("upsert repo page: %v", err)
}
repos, err := GetUserRepositories(db, user.DID, "")
if err != nil {
t.Fatalf("GetUserRepositories: %v", err)
}
// Order: repoA first (newer last_push), then repoB.
if len(repos) != 2 {
t.Fatalf("expected 2 repos, got %d: %#v", len(repos), repos)
}
if repos[0].Name != "repoA" || repos[1].Name != "repoB" {
t.Fatalf("expected order [repoA, repoB] (last_push DESC), got [%s, %s]", repos[0].Name, repos[1].Name)
}
// repoA grouping
a := repos[0]
if len(a.Tags) != 2 {
t.Errorf("repoA: expected 2 tags, got %d", len(a.Tags))
}
// tags ordered created_at DESC → v2 first
if len(a.Tags) >= 2 && (a.Tags[0].Tag != "v2" || a.Tags[1].Tag != "v1") {
t.Errorf("repoA tags out of order, want [v2, v1] got [%s, %s]", a.Tags[0].Tag, a.Tags[1].Tag)
}
if len(a.Manifests) != 2 {
t.Errorf("repoA: expected 2 manifests, got %d", len(a.Manifests))
}
// manifests ordered created_at DESC → a2 first
if len(a.Manifests) >= 2 && (a.Manifests[0].ID != manifestA2 || a.Manifests[1].ID != manifestA1) {
t.Errorf("repoA manifests out of order, want [a2, a1] got [%d, %d]", a.Manifests[0].ID, a.Manifests[1].ID)
}
if a.Title != "Repo A Title" || a.Description != "alpha" {
t.Errorf("repoA annotations not applied: title=%q desc=%q", a.Title, a.Description)
}
if a.IconURL != "https://example.com/a.png" {
t.Errorf("repoA icon: expected annotation URL, got %q", a.IconURL)
}
// repoB grouping + page-avatar override
b := repos[1]
if len(b.Tags) != 1 || b.Tags[0].Tag != "latest" {
t.Errorf("repoB tags: %#v", b.Tags)
}
if len(b.Manifests) != 1 || b.Manifests[0].Digest != "sha256:b1" {
t.Errorf("repoB manifests: %#v", b.Manifests)
}
if b.IconURL == "" {
t.Errorf("repoB icon should be derived from repo-page avatar CID, got empty")
}
// Cross-repo isolation: tags/manifests for repoB must not leak into repoA and vice versa.
for _, tag := range a.Tags {
if tag.Repository != "repoA" {
t.Errorf("repoA tag has wrong repository: %#v", tag)
}
}
for _, m := range b.Manifests {
if m.Repository != "repoB" {
t.Errorf("repoB manifest has wrong repository: %#v", m)
}
}
}
// TestGetUserRepositories_Empty verifies the bulk-fetch path short-circuits
// cleanly when the summary query returns no rows (no extra queries issued,
// nil slice returned).
func TestGetUserRepositories_Empty(t *testing.T) {
db, err := InitDB("file:TestGetUserRepositories_Empty?mode=memory&cache=shared", LibsqlConfig{})
if err != nil {
t.Fatalf("init db: %v", err)
}
defer db.Close()
user := &User{DID: "did:plc:nobody", Handle: "nobody.test", PDSEndpoint: "https://pds.example", LastSeen: time.Now()}
if err := UpsertUser(db, user); err != nil {
t.Fatalf("upsert user: %v", err)
}
repos, err := GetUserRepositories(db, user.DID, "")
if err != nil {
t.Fatalf("GetUserRepositories empty: %v", err)
}
if repos != nil {
t.Errorf("expected nil slice for user with no repos, got %#v", repos)
}
}
+211 -102
View File
@@ -19,6 +19,20 @@ import (
const LabelVersion int64 = labeling.ATPROTO_LABEL_VERSION
const schema = `
CREATE TABLE IF NOT EXISTS takedowns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
input TEXT NOT NULL,
subject_did TEXT NOT NULL,
subject_repo TEXT NOT NULL DEFAULT '',
subject_handle TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL DEFAULT '',
reversed_at TIMESTAMP,
reversed_by TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_takedowns_active ON takedowns(reversed_at, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_takedowns_subject ON takedowns(subject_did, subject_repo);
CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
src TEXT NOT NULL,
@@ -31,16 +45,21 @@ CREATE TABLE IF NOT EXISTS labels (
ver INTEGER NOT NULL DEFAULT 1,
sig BLOB NOT NULL,
subject_did TEXT NOT NULL,
subject_repo TEXT NOT NULL DEFAULT ''
subject_repo TEXT NOT NULL DEFAULT '',
takedown_id INTEGER REFERENCES takedowns(id)
);
CREATE INDEX IF NOT EXISTS idx_labels_subject ON labels(subject_did, subject_repo);
CREATE INDEX IF NOT EXISTS idx_labels_cts ON labels(cts DESC);
CREATE INDEX IF NOT EXISTS idx_labels_uri ON labels(uri);
CREATE INDEX IF NOT EXISTS idx_labels_takedown ON labels(takedown_id);
`
// Label represents an ATProto label record stored locally. Its on-the-wire representation
// is produced by ToLabeling() which round-trips through indigo's labeling package so the
// signature stays valid byte-for-byte.
//
// TakedownID is a labeler-internal pointer to the takedown event that produced this
// label (positive or negation). It's never serialized into ATProto wire format.
type Label struct {
ID int64
Src string
@@ -54,6 +73,23 @@ type Label struct {
Sig []byte
SubjectDID string
SubjectRepo string
TakedownID *int64
}
// Takedown is a single operator-issued takedown action. Each Takedown owns one or more
// Label rows linked by takedown_id. Reversal sets reversed_at / reversed_by in place.
type Takedown struct {
ID int64
Input string
SubjectDID string
SubjectRepo string
SubjectHandle string
Reason string
CreatedAt time.Time
CreatedBy string
ReversedAt *time.Time
ReversedBy string
LabelCount int
}
// LibsqlSync configures optional embedded-replica sync to a remote libSQL database.
@@ -233,12 +269,16 @@ func CreateLabel(db *sql.DB, l *Label) (int64, error) {
s := l.Exp.UTC().Format(time.RFC3339)
expStr = &s
}
var takedownID any
if l.TakedownID != nil {
takedownID = *l.TakedownID
}
result, err := db.Exec(
`INSERT INTO labels (src, uri, cid, val, neg, cts, exp, ver, sig, subject_did, subject_repo)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO labels (src, uri, cid, val, neg, cts, exp, ver, sig, subject_did, subject_repo, takedown_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.Src, l.URI, nullableString(l.CID), l.Val, l.Neg,
l.Cts.UTC().Format(time.RFC3339), expStr, l.Ver, l.Sig,
l.SubjectDID, l.SubjectRepo,
l.SubjectDID, l.SubjectRepo, takedownID,
)
if err != nil {
return 0, fmt.Errorf("failed to insert label: %w", err)
@@ -261,7 +301,7 @@ func nullableString(s string) any {
// GetLabelsSince returns labels with id > cursor, ordered by id ascending.
func GetLabelsSince(db *sql.DB, cursor int64, limit int) ([]Label, error) {
rows, err := db.Query(
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo, takedown_id
FROM labels WHERE id > ? ORDER BY id ASC LIMIT ?`,
cursor, limit,
)
@@ -284,34 +324,73 @@ func LatestSeq(db *sql.DB) (int64, error) {
return seq.Int64, nil
}
// ListActiveTakedowns returns active (non-negated) takedown labels.
func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) {
var total int
err := db.QueryRow(
`SELECT COUNT(*) FROM labels l1
WHERE l1.val = '!takedown' AND l1.neg = 0
AND NOT EXISTS (
SELECT 1 FROM labels l2
WHERE l2.src = l1.src AND l2.uri = l1.uri AND l2.val = l1.val
AND l2.neg = 1 AND l2.id > l1.id
)
AND (l1.exp IS NULL OR l1.exp > CURRENT_TIMESTAMP)`,
).Scan(&total)
// CreateTakedown inserts a takedown event row and returns its id. The id should then
// be stamped onto every label produced by this takedown (positive labels at issue time,
// negation labels at reversal time) so the audit trail stays linked.
func CreateTakedown(db *sql.DB, t *Takedown) (int64, error) {
if t.CreatedAt.IsZero() {
t.CreatedAt = time.Now().UTC()
}
result, err := db.Exec(
`INSERT INTO takedowns (input, subject_did, subject_repo, subject_handle, reason, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
t.Input, t.SubjectDID, t.SubjectRepo, t.SubjectHandle, t.Reason,
t.CreatedAt.UTC().Format(time.RFC3339), t.CreatedBy,
)
if err != nil {
return 0, fmt.Errorf("failed to insert takedown: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, err
}
t.ID = id
return id, nil
}
// GetTakedown loads a single takedown row by id. Returns sql.ErrNoRows when missing.
func GetTakedown(db *sql.DB, id int64) (*Takedown, error) {
row := db.QueryRow(
`SELECT t.id, t.input, t.subject_did, t.subject_repo, t.subject_handle, t.reason,
t.created_at, t.created_by, t.reversed_at, t.reversed_by,
(SELECT COUNT(*) FROM labels l WHERE l.takedown_id = t.id AND l.neg = 0)
FROM takedowns t WHERE t.id = ?`,
id,
)
return scanTakedown(row.Scan)
}
// TakedownFilter scopes ListTakedowns to active, reversed, or all rows.
type TakedownFilter int
const (
TakedownAll TakedownFilter = iota // every takedown row, regardless of reversal state
TakedownActive // only takedowns whose reversed_at is NULL
TakedownReversed // only takedowns whose reversed_at is set
)
// ListTakedowns returns takedown events ordered by created_at DESC, scoped by filter.
// The total count reflects the same filter.
func ListTakedowns(db *sql.DB, filter TakedownFilter, limit, offset int) ([]Takedown, int, error) {
where := ""
switch filter {
case TakedownActive:
where = "WHERE reversed_at IS NULL"
case TakedownReversed:
where = "WHERE reversed_at IS NOT NULL"
}
var total int
if err := db.QueryRow(`SELECT COUNT(*) FROM takedowns ` + where).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := db.Query(
`SELECT l1.id, l1.src, l1.uri, COALESCE(l1.cid, ''), l1.val, l1.neg, l1.cts, l1.exp, l1.ver, l1.sig, l1.subject_did, l1.subject_repo
FROM labels l1
WHERE l1.val = '!takedown' AND l1.neg = 0
AND NOT EXISTS (
SELECT 1 FROM labels l2
WHERE l2.src = l1.src AND l2.uri = l1.uri AND l2.val = l1.val
AND l2.neg = 1 AND l2.id > l1.id
)
AND (l1.exp IS NULL OR l1.exp > CURRENT_TIMESTAMP)
ORDER BY l1.cts DESC LIMIT ? OFFSET ?`,
`SELECT t.id, t.input, t.subject_did, t.subject_repo, t.subject_handle, t.reason,
t.created_at, t.created_by, t.reversed_at, t.reversed_by,
(SELECT COUNT(*) FROM labels l WHERE l.takedown_id = t.id AND l.neg = 0)
FROM takedowns t `+where+`
ORDER BY t.created_at DESC LIMIT ? OFFSET ?`,
limit, offset,
)
if err != nil {
@@ -319,18 +398,47 @@ func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) {
}
defer rows.Close()
labels, err := scanLabels(rows)
return labels, total, err
var out []Takedown
for rows.Next() {
t, err := scanTakedown(rows.Scan)
if err != nil {
return nil, 0, err
}
out = append(out, *t)
}
return out, total, rows.Err()
}
// GetLabelsForRepo returns all labels for a specific DID + repository.
func GetLabelsForRepo(db *sql.DB, did, repo string) ([]Label, error) {
// MarkTakedownReversed sets the reversed_at / reversed_by fields on the takedown row.
// Refuses to overwrite an existing reversal.
func MarkTakedownReversed(db *sql.DB, id int64, by string, at time.Time) error {
if at.IsZero() {
at = time.Now().UTC()
}
res, err := db.Exec(
`UPDATE takedowns SET reversed_at = ?, reversed_by = ?
WHERE id = ? AND reversed_at IS NULL`,
at.UTC().Format(time.RFC3339), by, id,
)
if err != nil {
return fmt.Errorf("failed to mark takedown reversed: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("takedown %d not found or already reversed", id)
}
return nil
}
// GetLabelsByTakedown returns all labels (positive + negations) linked to a takedown.
func GetLabelsByTakedown(db *sql.DB, takedownID int64) ([]Label, error) {
rows, err := db.Query(
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo
FROM labels
WHERE subject_did = ? AND subject_repo = ?
ORDER BY cts DESC`,
did, repo,
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo, takedown_id
FROM labels WHERE takedown_id = ? ORDER BY id ASC`,
takedownID,
)
if err != nil {
return nil, err
@@ -339,75 +447,36 @@ func GetLabelsForRepo(db *sql.DB, did, repo string) ([]Label, error) {
return scanLabels(rows)
}
// newNegationLabel constructs an unsigned negation label awaiting Sign().
func newNegationLabel(src, uri, val, did, repo string) *Label {
return &Label{
Src: src,
URI: uri,
Val: val,
Neg: true,
Cts: time.Now().UTC(),
SubjectDID: did,
SubjectRepo: repo,
}
}
// NegateRepoLabels signs+inserts negation labels for all active takedown labels on (DID, repo).
func NegateRepoLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did, repo string) ([]Label, error) {
// NegateTakedownLabels signs+inserts negation labels for every active (non-negated)
// label linked to the given takedown_id. Negations carry the same takedown_id so they
// remain part of the takedown's audit trail.
//
// The NOT EXISTS subquery skips URIs that already have a later neg=1 row (from a prior
// reversal call or from an external negation streamed in via subscribeLabels), so this
// function is idempotent and won't emit duplicate negations.
func NegateTakedownLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src string, takedownID int64) ([]Label, error) {
rows, err := db.Query(
`SELECT uri FROM labels
WHERE subject_did = ? AND subject_repo = ? AND val = '!takedown' AND neg = 0`,
did, repo,
`SELECT l1.uri, l1.subject_did, l1.subject_repo FROM labels l1
WHERE l1.takedown_id = ? AND l1.val = '!takedown' AND l1.neg = 0
AND NOT EXISTS (
SELECT 1 FROM labels l2
WHERE l2.src = l1.src AND l2.uri = l1.uri AND l2.val = l1.val
AND l2.neg = 1 AND l2.id > l1.id
)`,
takedownID,
)
if err != nil {
return nil, err
}
var uris []string
for rows.Next() {
var uri string
if err := rows.Scan(&uri); err != nil {
rows.Close()
return nil, err
}
uris = append(uris, uri)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]Label, 0, len(uris))
for _, uri := range uris {
neg := newNegationLabel(src, uri, "!takedown", did, repo)
if err := neg.Sign(key); err != nil {
return out, err
}
if _, err := CreateLabel(db, neg); err != nil {
return out, err
}
out = append(out, *neg)
}
return out, nil
}
// NegateUserLabels signs+inserts negation labels for all active takedown labels on a DID.
func NegateUserLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did string) ([]Label, error) {
rows, err := db.Query(
`SELECT uri, subject_repo FROM labels
WHERE subject_did = ? AND val = '!takedown' AND neg = 0`,
did,
)
if err != nil {
return nil, err
}
type uriRepo struct {
type entry struct {
uri string
did string
repo string
}
var entries []uriRepo
var entries []entry
for rows.Next() {
var e uriRepo
if err := rows.Scan(&e.uri, &e.repo); err != nil {
var e entry
if err := rows.Scan(&e.uri, &e.did, &e.repo); err != nil {
rows.Close()
return nil, err
}
@@ -418,9 +487,19 @@ func NegateUserLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did string)
return nil, err
}
id := takedownID
out := make([]Label, 0, len(entries))
for _, e := range entries {
neg := newNegationLabel(src, e.uri, "!takedown", did, e.repo)
neg := &Label{
Src: src,
URI: e.uri,
Val: "!takedown",
Neg: true,
Cts: time.Now().UTC(),
SubjectDID: e.did,
SubjectRepo: e.repo,
TakedownID: &id,
}
if err := neg.Sign(key); err != nil {
return out, err
}
@@ -432,13 +511,39 @@ func NegateUserLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did string)
return out, nil
}
func scanTakedown(scan func(...any) error) (*Takedown, error) {
var (
t Takedown
created string
revAt *string
)
if err := scan(
&t.ID, &t.Input, &t.SubjectDID, &t.SubjectRepo, &t.SubjectHandle, &t.Reason,
&created, &t.CreatedBy, &revAt, &t.ReversedBy, &t.LabelCount,
); err != nil {
return nil, err
}
if ts, err := time.Parse(time.RFC3339, created); err == nil {
t.CreatedAt = ts
}
if revAt != nil {
if ts, err := time.Parse(time.RFC3339, *revAt); err == nil {
t.ReversedAt = &ts
}
}
return &t, nil
}
func scanLabels(rows *sql.Rows) ([]Label, error) {
var labels []Label
for rows.Next() {
var l Label
var cts string
var exp *string
if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.CID, &l.Val, &l.Neg, &cts, &exp, &l.Ver, &l.Sig, &l.SubjectDID, &l.SubjectRepo); err != nil {
var (
l Label
cts string
exp *string
tdID sql.NullInt64
)
if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.CID, &l.Val, &l.Neg, &cts, &exp, &l.Ver, &l.Sig, &l.SubjectDID, &l.SubjectRepo, &tdID); err != nil {
return nil, err
}
if t, err := time.Parse(time.RFC3339, cts); err == nil {
@@ -449,6 +554,10 @@ func scanLabels(rows *sql.Rows) ([]Label, error) {
l.Exp = &t
}
}
if tdID.Valid {
id := tdID.Int64
l.TakedownID = &id
}
labels = append(labels, l)
}
return labels, rows.Err()
+121 -55
View File
@@ -151,7 +151,7 @@ func TestSignAndVerify(t *testing.T) {
}
}
func TestListActiveTakedowns(t *testing.T) {
func TestListTakedowns(t *testing.T) {
dir := t.TempDir()
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
@@ -159,36 +159,70 @@ func TestListActiveTakedowns(t *testing.T) {
src := "did:plc:labeler-1"
now := time.Now().UTC()
// Create three takedown events, each with a single summary label, so the
// label_count subquery has something to count.
var ids []int64
for i, repo := range []string{"repo1", "repo2", "repo3"} {
td := &Takedown{
Input: "atcr.io/r/did:plc:abc/" + repo,
SubjectDID: "did:plc:abc",
SubjectRepo: repo,
CreatedAt: now.Add(time.Duration(i) * time.Minute),
}
id, err := CreateTakedown(db, td)
if err != nil {
t.Fatalf("CreateTakedown: %v", err)
}
ids = append(ids, id)
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc/io.atcr.repo/" + repo,
Val: "!takedown", Cts: now.Add(time.Duration(i) * time.Minute),
Val: "!takedown", Cts: td.CreatedAt,
SubjectDID: "did:plc:abc", SubjectRepo: repo,
TakedownID: &id,
})
}
labels, total, err := ListActiveTakedowns(db, 10, 0)
tds, total, err := ListTakedowns(db, TakedownActive, 10, 0)
if err != nil {
t.Fatal(err)
}
if total != 3 || len(labels) != 3 {
t.Errorf("expected 3 active takedowns, got total=%d returned=%d", total, len(labels))
if total != 3 || len(tds) != 3 {
t.Errorf("expected 3 active takedowns, got total=%d returned=%d", total, len(tds))
}
for _, td := range tds {
if td.LabelCount != 1 {
t.Errorf("takedown %d label_count = %d, want 1", td.ID, td.LabelCount)
}
}
if _, err := NegateRepoLabels(db, key, src, "did:plc:abc", "repo2"); err != nil {
// Reverse the middle takedown.
if _, err := NegateTakedownLabels(db, key, src, ids[1]); err != nil {
t.Fatal(err)
}
if err := MarkTakedownReversed(db, ids[1], "did:plc:operator", time.Now().UTC()); err != nil {
t.Fatal(err)
}
_, total, err = ListActiveTakedowns(db, 10, 0)
_, activeTotal, err := ListTakedowns(db, TakedownActive, 10, 0)
if err != nil {
t.Fatal(err)
}
if total != 2 {
t.Errorf("expected 2 active takedowns after negation, got %d", total)
if activeTotal != 2 {
t.Errorf("expected 2 active takedowns after reversal, got %d", activeTotal)
}
revs, revTotal, err := ListTakedowns(db, TakedownReversed, 10, 0)
if err != nil {
t.Fatal(err)
}
if revTotal != 1 || len(revs) != 1 {
t.Errorf("expected 1 reversed takedown, got total=%d returned=%d", revTotal, len(revs))
}
if revs[0].ID != ids[1] || revs[0].ReversedAt == nil || revs[0].ReversedBy != "did:plc:operator" {
t.Errorf("reversed takedown row has wrong fields: %+v", revs[0])
}
}
func TestNegateRepoLabels(t *testing.T) {
func TestNegateTakedownLabels(t *testing.T) {
dir := t.TempDir()
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
@@ -197,6 +231,14 @@ func TestNegateRepoLabels(t *testing.T) {
now := time.Now().UTC()
did := "did:plc:abc"
tdID, err := CreateTakedown(db, &Takedown{
Input: "atcr.io/r/did:plc:abc/myimage", SubjectDID: did, SubjectRepo: "myimage",
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
uris := []string{
"at://did:plc:abc/io.atcr.manifest/sha256-111",
"at://did:plc:abc/io.atcr.manifest/sha256-222",
@@ -205,11 +247,11 @@ func TestNegateRepoLabels(t *testing.T) {
for _, uri := range uris {
signAndCreate(t, db, key, &Label{
Src: src, URI: uri, Val: "!takedown", Cts: now,
SubjectDID: did, SubjectRepo: "myimage",
SubjectDID: did, SubjectRepo: "myimage", TakedownID: &tdID,
})
}
negs, err := NegateRepoLabels(db, key, src, did, "myimage")
negs, err := NegateTakedownLabels(db, key, src, tdID)
if err != nil {
t.Fatal(err)
}
@@ -217,47 +259,57 @@ func TestNegateRepoLabels(t *testing.T) {
t.Errorf("expected %d negation labels, got %d", len(uris), len(negs))
}
_, total, err := ListActiveTakedowns(db, 10, 0)
// Negations must carry the same takedown_id so they're part of the audit trail.
all, err := GetLabelsByTakedown(db, tdID)
if err != nil {
t.Fatal(err)
}
if total != 0 {
t.Errorf("expected 0 active takedowns after repo negation, got %d", total)
if len(all) != 2*len(uris) {
t.Errorf("expected %d labels (positive + negation) for takedown %d, got %d", 2*len(uris), tdID, len(all))
}
var pos, neg int
for _, l := range all {
if l.TakedownID == nil || *l.TakedownID != tdID {
t.Errorf("label %d takedown_id = %v, want %d", l.ID, l.TakedownID, tdID)
}
if l.Neg {
neg++
} else {
pos++
}
}
if pos != len(uris) || neg != len(uris) {
t.Errorf("expected pos=%d neg=%d, got pos=%d neg=%d", len(uris), len(uris), pos, neg)
}
// Calling negate again must be a no-op (no remaining positive labels to flip).
negs2, err := NegateTakedownLabels(db, key, src, tdID)
if err != nil {
t.Fatal(err)
}
if len(negs2) != 0 {
t.Errorf("expected 0 negations on second call, got %d", len(negs2))
}
}
func TestNegateUserLabels(t *testing.T) {
func TestMarkTakedownReversed_RefusesDoubleReverse(t *testing.T) {
dir := t.TempDir()
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
src := "did:plc:labeler-1"
now := time.Now().UTC()
did := "did:plc:abc"
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc", Val: "!takedown", Cts: now,
SubjectDID: did,
id, err := CreateTakedown(db, &Takedown{
Input: "did:plc:abc", SubjectDID: "did:plc:abc",
})
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo1", Val: "!takedown", Cts: now,
SubjectDID: did, SubjectRepo: "repo1",
})
negs, err := NegateUserLabels(db, key, src, did)
if err != nil {
t.Fatal(err)
}
if len(negs) != 2 {
t.Errorf("expected 2 negation labels, got %d", len(negs))
if err := MarkTakedownReversed(db, id, "did:plc:op", time.Now().UTC()); err != nil {
t.Fatalf("first reverse: %v", err)
}
_, total, err := ListActiveTakedowns(db, 10, 0)
if err != nil {
t.Fatal(err)
if err := MarkTakedownReversed(db, id, "did:plc:op", time.Now().UTC()); err == nil {
t.Error("expected second reverse to fail (already reversed)")
}
if total != 0 {
t.Errorf("expected 0 active takedowns after user negation, got %d", total)
if err := MarkTakedownReversed(db, 9999, "did:plc:op", time.Now().UTC()); err == nil {
t.Error("expected reverse on unknown id to fail")
}
}
@@ -326,7 +378,7 @@ func TestLatestSeq(t *testing.T) {
}
}
func TestGetLabelsForRepo(t *testing.T) {
func TestGetLabelsByTakedown(t *testing.T) {
dir := t.TempDir()
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
@@ -334,40 +386,54 @@ func TestGetLabelsForRepo(t *testing.T) {
src := "did:plc:labeler-1"
now := time.Now().UTC()
tdA, err := CreateTakedown(db, &Takedown{Input: "did:plc:abc/repo1", SubjectDID: "did:plc:abc", SubjectRepo: "repo1", CreatedAt: now})
if err != nil {
t.Fatal(err)
}
tdB, err := CreateTakedown(db, &Takedown{Input: "did:plc:def/repo1", SubjectDID: "did:plc:def", SubjectRepo: "repo1", CreatedAt: now})
if err != nil {
t.Fatal(err)
}
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo1",
Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "repo1",
Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "repo1", TakedownID: &tdA,
})
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo2",
Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "repo2",
Src: src, URI: "at://did:plc:abc/io.atcr.manifest/sha256-aaa",
Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "repo1", TakedownID: &tdA,
})
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:def/io.atcr.repo/repo1",
Val: "!takedown", Cts: now, SubjectDID: "did:plc:def", SubjectRepo: "repo1",
Val: "!takedown", Cts: now, SubjectDID: "did:plc:def", SubjectRepo: "repo1", TakedownID: &tdB,
})
labels, err := GetLabelsForRepo(db, "did:plc:abc", "repo1")
labels, err := GetLabelsByTakedown(db, tdA)
if err != nil {
t.Fatal(err)
}
if len(labels) != 2 {
t.Errorf("takedown A: expected 2 labels, got %d", len(labels))
}
for _, l := range labels {
if l.TakedownID == nil || *l.TakedownID != tdA {
t.Errorf("label %d has wrong takedown_id: %v", l.ID, l.TakedownID)
}
}
labels, err = GetLabelsByTakedown(db, tdB)
if err != nil {
t.Fatal(err)
}
if len(labels) != 1 {
t.Errorf("expected 1 label for did:plc:abc/repo1, got %d", len(labels))
t.Errorf("takedown B: expected 1 label, got %d", len(labels))
}
labels, err = GetLabelsForRepo(db, "did:plc:def", "repo1")
if err != nil {
t.Fatal(err)
}
if len(labels) != 1 {
t.Errorf("expected 1 label for did:plc:def/repo1, got %d", len(labels))
}
labels, err = GetLabelsForRepo(db, "did:plc:xyz", "repo1")
labels, err = GetLabelsByTakedown(db, 9999)
if err != nil {
t.Fatal(err)
}
if len(labels) != 0 {
t.Errorf("expected 0 labels for unknown did, got %d", len(labels))
t.Errorf("unknown takedown id: expected 0 labels, got %d", len(labels))
}
}
+203 -64
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -19,6 +20,11 @@ type TakedownInput struct {
DID string
Handle string
Repository string // empty = user-level takedown
// Operator-supplied context. Captured into the takedowns row so we can show
// who/why/what-was-typed on the dashboard. None are required.
RawInput string // exact string the operator submitted (URL, did, handle, AT URI)
Reason string // optional free-text note
CreatedBy string // operator DID from session, "" if unknown
}
// ParseTakedownInput parses various input formats into a TakedownInput.
@@ -165,6 +171,7 @@ func resolveIdentifier(ctx context.Context, identifier string) (did, handle stri
// TakedownResult contains the results of a takedown operation.
type TakedownResult struct {
TakedownID int64
DID string
Handle string
Repository string
@@ -172,11 +179,39 @@ type TakedownResult struct {
UserLevel bool
}
// ExecuteTakedown creates takedown labels for a repo or user.
// ExecuteTakedown creates a takedown event row and the labels that belong to it.
// Every label (the user-level label, the per-record labels discovered via PDS, and the
// repo-level summary label) carries the new takedown_id so reversal can target the
// exact set without re-querying by subject.
func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*TakedownResult, error) {
src := s.did
now := time.Now().UTC()
td := &Takedown{
Input: input.RawInput,
SubjectDID: input.DID,
SubjectRepo: input.Repository,
SubjectHandle: input.Handle,
Reason: input.Reason,
CreatedAt: now,
CreatedBy: input.CreatedBy,
}
if td.Input == "" {
// Fallback so the dashboard always has something to show, even if a caller
// (e.g. a future API) didn't pass the original string.
if input.Repository != "" {
td.Input = fmt.Sprintf("%s/%s", input.DID, input.Repository)
} else {
td.Input = input.DID
}
}
takedownID, err := CreateTakedown(s.db, td)
if err != nil {
return nil, fmt.Errorf("failed to create takedown event: %w", err)
}
result := &TakedownResult{
TakedownID: takedownID,
DID: input.DID,
Handle: input.Handle,
Repository: input.Repository,
@@ -184,7 +219,7 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta
}
if input.Repository == "" {
// User-level takedown
// User-level takedown: a single label on at://<did>.
label := &Label{
Src: src,
URI: "at://" + input.DID,
@@ -192,6 +227,7 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta
Cts: now,
SubjectDID: input.DID,
SubjectRepo: "",
TakedownID: &takedownID,
}
if err := label.Sign(s.signingKey); err != nil {
return nil, fmt.Errorf("failed to sign user-level label: %w", err)
@@ -201,19 +237,20 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta
}
s.hub.Broadcast(label)
result.Labels = append(result.Labels, *label)
slog.Info("Created user-level takedown", "did", input.DID, "handle", input.Handle)
slog.Info("Created user-level takedown", "takedown_id", takedownID, "did", input.DID, "handle", input.Handle)
return result, nil
}
// Repo-level takedown: discover all records from PDS
labels, err := s.discoverAndLabelRecords(ctx, input.DID, input.Repository, src, now)
// Repo-level takedown: discover all records from PDS and label each.
labels, err := s.discoverAndLabelRecords(ctx, input.DID, input.Repository, src, now, takedownID)
if err != nil {
// Even if PDS discovery fails, create a repo-level summary label
// Even if PDS discovery fails, create a repo-level summary label so reads
// against the well-known summary URI still see the takedown.
slog.Warn("PDS discovery failed, creating summary label only", "error", err)
}
result.Labels = append(result.Labels, labels...)
// Always create a repo-level summary label for efficient filtering
// Always create a repo-level summary label for efficient filtering.
summaryLabel := &Label{
Src: src,
URI: fmt.Sprintf("at://%s/io.atcr.repo/%s", input.DID, input.Repository),
@@ -221,6 +258,7 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta
Cts: now,
SubjectDID: input.DID,
SubjectRepo: input.Repository,
TakedownID: &takedownID,
}
if err := summaryLabel.Sign(s.signingKey); err != nil {
return nil, fmt.Errorf("failed to sign summary label: %w", err)
@@ -232,6 +270,7 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta
result.Labels = append(result.Labels, *summaryLabel)
slog.Info("Created repo-level takedown",
"takedown_id", takedownID,
"did", input.DID,
"handle", input.Handle,
"repository", input.Repository,
@@ -241,9 +280,49 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta
return result, nil
}
// ReverseTakedown negates every active label belonging to the given takedown event and
// marks the event row as reversed. Refuses to act on a takedown that doesn't exist or
// has already been reversed.
func (s *Server) ReverseTakedown(ctx context.Context, takedownID int64, reversedBy string) (*TakedownResult, error) {
td, err := GetTakedown(s.db, takedownID)
if err != nil {
return nil, fmt.Errorf("failed to load takedown %d: %w", takedownID, err)
}
if td.ReversedAt != nil {
return nil, fmt.Errorf("takedown %d already reversed at %s", takedownID, td.ReversedAt.Format(time.RFC3339))
}
negs, err := NegateTakedownLabels(s.db, s.signingKey, s.did, takedownID)
if err != nil {
return nil, fmt.Errorf("failed to negate labels for takedown %d: %w", takedownID, err)
}
for i := range negs {
s.hub.Broadcast(&negs[i])
}
if err := MarkTakedownReversed(s.db, takedownID, reversedBy, time.Now().UTC()); err != nil {
return nil, err
}
slog.Info("Reversed takedown",
"takedown_id", takedownID,
"did", td.SubjectDID,
"repository", td.SubjectRepo,
"reversed_by", reversedBy,
"negations", len(negs),
)
return &TakedownResult{
TakedownID: takedownID,
DID: td.SubjectDID,
Handle: td.SubjectHandle,
Repository: td.SubjectRepo,
Labels: negs,
UserLevel: td.SubjectRepo == "",
}, nil
}
// discoverAndLabelRecords queries the user's PDS for all records in the given repo
// and creates takedown labels for each.
func (s *Server) discoverAndLabelRecords(ctx context.Context, did, repo, src string, now time.Time) ([]Label, error) {
// and creates takedown labels for each, all linked to takedownID.
func (s *Server) discoverAndLabelRecords(ctx context.Context, did, repo, src string, now time.Time, takedownID int64) ([]Label, error) {
_, _, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did)
if err != nil {
return nil, fmt.Errorf("failed to resolve DID: %w", err)
@@ -282,6 +361,7 @@ func (s *Server) discoverAndLabelRecords(ctx context.Context, did, repo, src str
Cts: now,
SubjectDID: did,
SubjectRepo: repo,
TakedownID: &takedownID,
}
if err := label.Sign(s.signingKey); err != nil {
slog.Warn("Failed to sign label", "uri", uri, "error", err)
@@ -314,11 +394,17 @@ func extractRepoField(value json.RawMessage, collection string) string {
// Handlers
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
labels, total, err := ListActiveTakedowns(s.db, 50, 0)
active, activeTotal, err := ListTakedowns(s.db, TakedownActive, 50, 0)
if err != nil {
http.Error(w, "Failed to list takedowns", http.StatusInternalServerError)
return
}
reversed, reversedTotal, err := ListTakedowns(s.db, TakedownReversed, 50, 0)
if err != nil {
http.Error(w, "Failed to list reversed takedowns", http.StatusInternalServerError)
return
}
csrf := ""
if session := SessionFromContext(r.Context()); session != nil {
csrf = session.CSRFToken
@@ -329,16 +415,18 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
<html>
<head><title>%s Labeler</title>
<style>
body{font-family:system-ui;max-width:900px;margin:40px auto;padding:0 20px}
body{font-family:system-ui;max-width:1000px;margin:40px auto;padding:0 20px}
table{width:100%%;border-collapse:collapse;margin:20px 0}
th,td{text-align:left;padding:8px;border-bottom:1px solid #ddd}
th,td{text-align:left;padding:8px;border-bottom:1px solid #ddd;vertical-align:top}
th{background:#f5f5f5}
.badge{background:#dc2626;color:white;padding:2px 8px;border-radius:4px;font-size:0.85em}
.muted{color:#666;font-size:0.9em}
a{color:#2563eb}
nav{display:flex;gap:16px;margin-bottom:24px}
.btn{padding:8px 16px;background:#2563eb;color:white;text-decoration:none;border-radius:4px;border:none;cursor:pointer}
.btn-danger{background:#dc2626}
form{display:inline}
code{background:#f4f4f5;padding:1px 4px;border-radius:3px}
.reason{max-width:280px;white-space:pre-wrap}
</style>
</head>
<body>
@@ -351,40 +439,89 @@ form{display:inline}
<h2>Active Takedowns (%d)</h2>`,
s.config.Server.ClientShortName,
s.config.Server.ClientShortName,
total,
activeTotal,
)
if len(labels) == 0 {
fmt.Fprint(w, `<p>No active takedowns.</p>`)
} else {
fmt.Fprint(w, `<table><tr><th>Subject</th><th>Repository</th><th>URI</th><th>Created</th><th>Action</th></tr>`)
for _, l := range labels {
repoDisplay := l.SubjectRepo
if repoDisplay == "" {
repoDisplay = "<em>all repos (user-level)</em>"
}
fmt.Fprintf(w, `<tr>
<td>%s</td>
<td>%s</td>
<td><code>%s</code></td>
<td>%s</td>
<td><form method="POST" action="/reverse">%s<input type="hidden" name="did" value="%s"><input type="hidden" name="repo" value="%s"><button type="submit" class="btn btn-danger" onclick="return confirm('Reverse this takedown?')">Reverse</button></form></td>
</tr>`,
template.HTMLEscapeString(l.SubjectDID),
repoDisplay,
template.HTMLEscapeString(l.URI),
l.Cts.Format("2006-01-02 15:04"),
csrfInputHTML(csrf),
template.HTMLEscapeString(l.SubjectDID),
template.HTMLEscapeString(l.SubjectRepo),
)
}
fmt.Fprint(w, `</table>`)
}
renderTakedownRows(w, active, csrf, true)
fmt.Fprintf(w, `<h2>Reversed (%d)</h2>`, reversedTotal)
renderTakedownRows(w, reversed, csrf, false)
fmt.Fprint(w, `</body></html>`)
}
// renderTakedownRows writes either an active table (with a Reverse button) or a
// reversed-history table (with a reversed-at column instead).
func renderTakedownRows(w http.ResponseWriter, ts []Takedown, csrf string, withReverse bool) {
if len(ts) == 0 {
if withReverse {
fmt.Fprint(w, `<p class="muted">No active takedowns.</p>`)
} else {
fmt.Fprint(w, `<p class="muted">No reversed takedowns yet.</p>`)
}
return
}
fmt.Fprint(w, `<table><tr><th>Input</th><th>Subject</th><th>Reason</th><th>Labels</th><th>Created</th>`)
if withReverse {
fmt.Fprint(w, `<th>Action</th>`)
} else {
fmt.Fprint(w, `<th>Reversed</th>`)
}
fmt.Fprint(w, `</tr>`)
for _, t := range ts {
subject := template.HTMLEscapeString(t.SubjectDID)
if t.SubjectHandle != "" {
subject = fmt.Sprintf(`%s<br><span class="muted">%s</span>`,
template.HTMLEscapeString(t.SubjectHandle), subject)
}
if t.SubjectRepo != "" {
subject += fmt.Sprintf(` / <code>%s</code>`, template.HTMLEscapeString(t.SubjectRepo))
} else {
subject += ` <span class="muted">(user-level)</span>`
}
reason := template.HTMLEscapeString(t.Reason)
if reason == "" {
reason = `<span class="muted">—</span>`
}
var lastCol string
if withReverse {
lastCol = fmt.Sprintf(
`<form method="POST" action="/reverse">%s<input type="hidden" name="takedown_id" value="%d"><button type="submit" class="btn btn-danger" onclick="return confirm('Reverse this takedown?')">Reverse</button></form>`,
csrfInputHTML(csrf), t.ID,
)
} else {
rev := ""
if t.ReversedAt != nil {
rev = t.ReversedAt.Format("2006-01-02 15:04")
}
by := ""
if t.ReversedBy != "" {
by = fmt.Sprintf(`<br><span class="muted">by %s</span>`, template.HTMLEscapeString(t.ReversedBy))
}
lastCol = rev + by
}
fmt.Fprintf(w, `<tr>
<td><code>%s</code></td>
<td>%s</td>
<td class="reason">%s</td>
<td>%d</td>
<td>%s</td>
<td>%s</td>
</tr>`,
template.HTMLEscapeString(t.Input),
subject,
reason,
t.LabelCount,
t.CreatedAt.Format("2006-01-02 15:04"),
lastCol,
)
}
fmt.Fprint(w, `</table>`)
}
func (s *Server) handleTakedownForm(w http.ResponseWriter, r *http.Request) {
msg := r.URL.Query().Get("msg")
errorMsg := r.URL.Query().Get("error")
@@ -431,6 +568,10 @@ nav{display:flex;gap:16px;margin-bottom:24px}
<label for="target"><strong>Target</strong></label>
<input type="text" id="target" name="target" placeholder="/r/handle/repo, /u/handle, at://did/collection/rkey, handle, or did:..." required>
<p class="help">Repo: <code>/r/handle/repo</code> (or full atcr.io URL). User-level: <code>/u/handle</code>, a bare handle, or a DID. AT URIs (<code>at://...</code>) also work.</p>
<label for="reason"><strong>Reason</strong> <span class="help">(optional, internal note)</span></label>
<textarea id="reason" name="reason" rows="3" placeholder="Why is this being taken down? Visible only to labeler operators."></textarea>
<br>
<button type="submit" class="btn" onclick="return confirm('Issue takedown? This will suppress the content immediately.')">Issue Takedown</button>
</form>
@@ -443,12 +584,18 @@ func (s *Server) handleTakedownSubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/takedown?error=Target+is+required", http.StatusFound)
return
}
reason := strings.TrimSpace(r.FormValue("reason"))
input, err := ParseTakedownInput(r.Context(), target)
if err != nil {
http.Redirect(w, r, "/takedown?error="+strings.ReplaceAll(err.Error(), " ", "+"), http.StatusFound)
return
}
input.RawInput = target
input.Reason = reason
if session := SessionFromContext(r.Context()); session != nil {
input.CreatedBy = session.DID
}
result, err := s.ExecuteTakedown(r.Context(), input)
if err != nil {
@@ -456,7 +603,7 @@ func (s *Server) handleTakedownSubmit(w http.ResponseWriter, r *http.Request) {
return
}
msg := fmt.Sprintf("Takedown issued: %d labels created for %s", len(result.Labels), result.DID)
msg := fmt.Sprintf("Takedown #%d issued: %d labels created for %s", result.TakedownID, len(result.Labels), result.DID)
if result.Repository != "" {
msg += "/" + result.Repository
}
@@ -464,35 +611,27 @@ func (s *Server) handleTakedownSubmit(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleReverse(w http.ResponseWriter, r *http.Request) {
did := strings.TrimSpace(r.FormValue("did"))
repo := strings.TrimSpace(r.FormValue("repo"))
if did == "" {
http.Redirect(w, r, "/?error=DID+is+required", http.StatusFound)
idStr := strings.TrimSpace(r.FormValue("takedown_id"))
if idStr == "" {
http.Redirect(w, r, "/?error=Missing+takedown_id", http.StatusFound)
return
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
http.Redirect(w, r, "/?error=Invalid+takedown_id", http.StatusFound)
return
}
src := s.did
var (
negs []Label
err error
)
if repo == "" {
negs, err = NegateUserLabels(s.db, s.signingKey, src, did)
} else {
negs, err = NegateRepoLabels(s.db, s.signingKey, src, did, repo)
reversedBy := ""
if session := SessionFromContext(r.Context()); session != nil {
reversedBy = session.DID
}
if err != nil {
slog.Error("Failed to reverse takedown", "did", did, "repo", repo, "error", err)
http.Redirect(w, r, "/?error=Failed+to+reverse+takedown", http.StatusFound)
if _, err := s.ReverseTakedown(r.Context(), id, reversedBy); err != nil {
slog.Error("Failed to reverse takedown", "takedown_id", id, "error", err)
http.Redirect(w, r, "/?error="+strings.ReplaceAll(err.Error(), " ", "+"), http.StatusFound)
return
}
for i := range negs {
s.hub.Broadcast(&negs[i])
}
slog.Info("Reversed takedown", "did", did, "repo", repo, "negations", len(negs))
http.Redirect(w, r, "/", http.StatusFound)
}