mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-01 15:56:58 +00:00
722 lines
24 KiB
Go
722 lines
24 KiB
Go
package labeler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
)
|
|
|
|
// TakedownInput represents parsed takedown input.
|
|
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.
|
|
//
|
|
// Supported shapes (dispatched in order):
|
|
//
|
|
// - at://<did-or-handle>[/collection/rkey] — ATProto AT URI
|
|
// - did:plc:..., did:web:... — bare DID, user-level takedown
|
|
// - URL with /u/<handle> or /r/<handle>/<repo> — appview routes (with or without scheme)
|
|
// - <handle> — bare handle, user-level takedown
|
|
//
|
|
// Anything else is rejected. The appview's /r/ route uses the repo name as a single
|
|
// path segment so any trailing path (digest pages, tag tabs) is discarded; URL
|
|
// fragments and query strings are dropped in all cases.
|
|
func ParseTakedownInput(ctx context.Context, input string) (*TakedownInput, error) {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return nil, fmt.Errorf("empty takedown input")
|
|
}
|
|
|
|
if strings.HasPrefix(input, "at://") {
|
|
return parseATURI(ctx, input)
|
|
}
|
|
|
|
// Bare DID — no slashes, no scheme. did:plc:..., did:web:..., did:web:host%3Aport.
|
|
if strings.HasPrefix(input, "did:") && !strings.Contains(input, "/") {
|
|
return resolveBareIdentifier(ctx, input)
|
|
}
|
|
|
|
// URL-shaped: contains a scheme or a slash. Parse and dispatch on the path.
|
|
if hasURLShape(input) {
|
|
return parseTakedownURL(ctx, input)
|
|
}
|
|
|
|
// Otherwise: bare handle.
|
|
return resolveBareIdentifier(ctx, input)
|
|
}
|
|
|
|
// hasURLShape reports whether the input looks like a URL or a path. A bare handle like
|
|
// "alice.bsky.social" is not URL-shaped (no slashes, no scheme).
|
|
func hasURLShape(s string) bool {
|
|
return strings.Contains(s, "://") || strings.Contains(s, "/")
|
|
}
|
|
|
|
// parseTakedownURL parses a URL whose path is one of the appview's takedown-relevant
|
|
// routes: /u/<handle> for user-level, /r/<handle>/<repo> for repo-level. The host part
|
|
// is irrelevant — we only use the path — so this also accepts schemeless input like
|
|
// "atcr.io/r/handle/repo" by prepending https:// before parsing.
|
|
func parseTakedownURL(ctx context.Context, input string) (*TakedownInput, error) {
|
|
if !strings.Contains(input, "://") {
|
|
input = "https://" + input
|
|
}
|
|
u, err := url.Parse(input)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
// No path — treat the host as the identifier (e.g. "alice.bsky.social/").
|
|
return resolveBareIdentifier(ctx, u.Host)
|
|
}
|
|
|
|
switch parts[0] {
|
|
case "u":
|
|
if len(parts) < 2 || parts[1] == "" {
|
|
return nil, fmt.Errorf("missing handle in /u/<handle>")
|
|
}
|
|
return resolveBareIdentifier(ctx, parts[1])
|
|
case "r":
|
|
if len(parts) < 3 || parts[1] == "" || parts[2] == "" {
|
|
return nil, fmt.Errorf("missing handle or repo in /r/<handle>/<repo>")
|
|
}
|
|
base, err := resolveBareIdentifier(ctx, parts[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// parts[2] only — discard any /digest/..., /tags/..., etc. trailing path.
|
|
base.Repository = parts[2]
|
|
return base, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported URL path %q (expected /u/<handle> or /r/<handle>/<repo>)", u.Path)
|
|
}
|
|
}
|
|
|
|
// parseATURI parses an at:// URI. The authority is a DID or handle; the path's third
|
|
// segment (rkey) becomes the repo for repo-level takedowns. Fragment and query are
|
|
// stripped first since paste-of-browser-AT-URI may include them.
|
|
func parseATURI(ctx context.Context, uri string) (*TakedownInput, error) {
|
|
trimmed := strings.TrimPrefix(uri, "at://")
|
|
if idx := strings.IndexAny(trimmed, "#?"); idx >= 0 {
|
|
trimmed = trimmed[:idx]
|
|
}
|
|
parts := strings.SplitN(trimmed, "/", 3)
|
|
authority := parts[0]
|
|
if authority == "" {
|
|
return nil, fmt.Errorf("at:// URI missing authority")
|
|
}
|
|
|
|
var (
|
|
did, handle string
|
|
err error
|
|
)
|
|
if strings.HasPrefix(authority, "did:") {
|
|
did = authority
|
|
_, handle, _, _ = atproto.ResolveIdentity(ctx, did)
|
|
} else {
|
|
did, handle, err = resolveIdentifier(ctx, authority)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
out := &TakedownInput{DID: did, Handle: handle}
|
|
if len(parts) >= 3 {
|
|
out.Repository = parts[2]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// resolveBareIdentifier resolves a handle or DID to a user-level TakedownInput. When
|
|
// the input is already a DID, the resolve is best-effort (DID is the source of truth;
|
|
// handle is just for display) and we don't fail if PLC/web resolution is unreachable.
|
|
// For a handle, resolution is required since we need a DID to label.
|
|
func resolveBareIdentifier(ctx context.Context, id string) (*TakedownInput, error) {
|
|
if strings.HasPrefix(id, "did:") {
|
|
_, handle, _, _ := atproto.ResolveIdentity(ctx, id)
|
|
return &TakedownInput{DID: id, Handle: handle}, nil
|
|
}
|
|
did, handle, err := resolveIdentifier(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &TakedownInput{DID: did, Handle: handle}, nil
|
|
}
|
|
|
|
func resolveIdentifier(ctx context.Context, identifier string) (did, handle string, err error) {
|
|
did, handle, _, err = atproto.ResolveIdentity(ctx, identifier)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to resolve %q: %w", identifier, err)
|
|
}
|
|
return did, handle, nil
|
|
}
|
|
|
|
// TakedownResult contains the results of a takedown operation.
|
|
type TakedownResult struct {
|
|
TakedownID int64
|
|
DID string
|
|
Handle string
|
|
Repository string
|
|
Labels []Label
|
|
UserLevel bool
|
|
}
|
|
|
|
// 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,
|
|
UserLevel: input.Repository == "",
|
|
}
|
|
|
|
if input.Repository == "" {
|
|
// User-level takedown: a single label on at://<did>.
|
|
label := &Label{
|
|
Src: src,
|
|
URI: "at://" + input.DID,
|
|
Val: "!takedown",
|
|
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)
|
|
}
|
|
if _, err := CreateLabel(s.db, label); err != nil {
|
|
return nil, fmt.Errorf("failed to create user-level label: %w", err)
|
|
}
|
|
s.hub.Broadcast(label)
|
|
result.Labels = append(result.Labels, *label)
|
|
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 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 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.
|
|
summaryLabel := &Label{
|
|
Src: src,
|
|
URI: fmt.Sprintf("at://%s/io.atcr.repo/%s", input.DID, input.Repository),
|
|
Val: "!takedown",
|
|
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)
|
|
}
|
|
if _, err := CreateLabel(s.db, summaryLabel); err != nil {
|
|
return nil, fmt.Errorf("failed to create summary label: %w", err)
|
|
}
|
|
s.hub.Broadcast(summaryLabel)
|
|
result.Labels = append(result.Labels, *summaryLabel)
|
|
|
|
slog.Info("Created repo-level takedown",
|
|
"takedown_id", takedownID,
|
|
"did", input.DID,
|
|
"handle", input.Handle,
|
|
"repository", input.Repository,
|
|
"label_count", len(result.Labels),
|
|
)
|
|
|
|
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, 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)
|
|
}
|
|
|
|
client := atproto.NewClient(pdsEndpoint, did, "")
|
|
var labels []Label
|
|
|
|
// Collections to search
|
|
collections := []string{
|
|
atproto.ManifestCollection,
|
|
atproto.TagCollection,
|
|
atproto.RepoPageCollection,
|
|
}
|
|
|
|
for _, collection := range collections {
|
|
records, _, err := client.ListRecordsForRepo(ctx, did, collection, 100, "")
|
|
if err != nil {
|
|
slog.Warn("Failed to list records", "collection", collection, "error", err)
|
|
continue
|
|
}
|
|
|
|
for _, rec := range records {
|
|
// Filter by repository field
|
|
recRepo := extractRepoField(rec.Value, collection)
|
|
if recRepo != repo {
|
|
continue
|
|
}
|
|
|
|
// Use the full AT URI from the record (at://did/collection/rkey)
|
|
uri := rec.URI
|
|
label := &Label{
|
|
Src: src,
|
|
URI: uri,
|
|
Val: "!takedown",
|
|
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)
|
|
continue
|
|
}
|
|
if _, err := CreateLabel(s.db, label); err != nil {
|
|
slog.Warn("Failed to create label", "uri", uri, "error", err)
|
|
continue
|
|
}
|
|
s.hub.Broadcast(label)
|
|
labels = append(labels, *label)
|
|
}
|
|
}
|
|
|
|
return labels, nil
|
|
}
|
|
|
|
// extractRepoField extracts the repository name from a record's JSON value.
|
|
func extractRepoField(value json.RawMessage, collection string) string {
|
|
// For repo pages, the rkey IS the repository name, but we also check the value
|
|
var rec struct {
|
|
Repository string `json:"repository"`
|
|
}
|
|
if err := json.Unmarshal(value, &rec); err == nil && rec.Repository != "" {
|
|
return rec.Repository
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Handlers
|
|
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
// Pre-fetch labels per visible takedown so the expand-in-place rows can render
|
|
// without a round-trip. N+1 queries are fine here — both lists are paginated to
|
|
// 50, this is admin-only, and it keeps the UI JS-light (just a toggle).
|
|
labelsByTakedown := make(map[int64][]Label, len(active)+len(reversed))
|
|
for _, t := range append(append([]Takedown{}, active...), reversed...) {
|
|
labels, err := GetLabelsByTakedown(s.db, t.ID)
|
|
if err != nil {
|
|
slog.Warn("Failed to load labels for takedown", "takedown_id", t.ID, "error", err)
|
|
continue
|
|
}
|
|
labelsByTakedown[t.ID] = labels
|
|
}
|
|
|
|
csrf := ""
|
|
if session := SessionFromContext(r.Context()); session != nil {
|
|
csrf = session.CSRFToken
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<!DOCTYPE html>
|
|
<html>
|
|
<head><title>%s Labeler</title>
|
|
<style>
|
|
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;vertical-align:top}
|
|
th{background:#f5f5f5}
|
|
.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}
|
|
.toggle{background:none;border:1px solid #d4d4d8;border-radius:4px;padding:2px 8px;font:inherit;cursor:pointer;color:#374151}
|
|
.toggle:hover{background:#f4f4f5}
|
|
.toggle .caret{display:inline-block;transition:transform .15s ease;margin-right:4px}
|
|
.toggle[aria-expanded="true"] .caret{transform:rotate(90deg)}
|
|
.detail-row td{background:#fafafa;padding:0}
|
|
.detail-row .inner{padding:12px 16px}
|
|
.label-list{width:100%%;border-collapse:collapse;font-size:0.88em}
|
|
.label-list th,.label-list td{border-bottom:1px solid #eee;padding:4px 8px;background:#fafafa}
|
|
.label-list th{background:#f1f1f3}
|
|
.tag{display:inline-block;padding:1px 6px;border-radius:3px;font-size:0.85em}
|
|
.tag-active{background:#fee2e2;color:#991b1b}
|
|
.tag-neg{background:#dcfce7;color:#166534}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>%s Labeler</h1>
|
|
<nav>
|
|
<a href="/" class="btn">Dashboard</a>
|
|
<a href="/takedown" class="btn">New Takedown</a>
|
|
<a href="/auth/logout">Logout</a>
|
|
</nav>
|
|
<h2>Active Takedowns (%d)</h2>`,
|
|
s.config.Labeler.ClientShortName,
|
|
s.config.Labeler.ClientShortName,
|
|
activeTotal,
|
|
)
|
|
|
|
renderTakedownRows(w, active, labelsByTakedown, csrf, true)
|
|
|
|
fmt.Fprintf(w, `<h2>Reversed (%d)</h2>`, reversedTotal)
|
|
renderTakedownRows(w, reversed, labelsByTakedown, csrf, false)
|
|
|
|
// Tiny inline toggle: flips [hidden] on the sibling detail row and the
|
|
// aria-expanded attribute on the button (which the .caret CSS rotates).
|
|
fmt.Fprint(w, `<script>
|
|
document.addEventListener('click', function(e) {
|
|
var btn = e.target.closest('.toggle[data-target]');
|
|
if (!btn) return;
|
|
var row = document.getElementById(btn.dataset.target);
|
|
if (!row) return;
|
|
var open = row.hasAttribute('hidden');
|
|
if (open) { row.removeAttribute('hidden'); } else { row.setAttribute('hidden', ''); }
|
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
});
|
|
</script>
|
|
</body></html>`)
|
|
}
|
|
|
|
// renderTakedownRows writes either an active table (with a Reverse button) or a
|
|
// reversed-history table (with a reversed-at column instead). Each main row is
|
|
// followed by a hidden detail row that the inline JS toggles to show the labels
|
|
// linked to that takedown.
|
|
func renderTakedownRows(w http.ResponseWriter, ts []Takedown, labelsByID map[int64][]Label, 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
|
|
}
|
|
const totalCols = 6
|
|
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
|
|
}
|
|
|
|
detailID := fmt.Sprintf("td-%d-detail", t.ID)
|
|
fmt.Fprintf(w, `<tr>
|
|
<td><code>%s</code></td>
|
|
<td>%s</td>
|
|
<td class="reason">%s</td>
|
|
<td><button type="button" class="toggle" data-target="%s" aria-expanded="false" aria-controls="%s"><span class="caret">▶</span>%d</button></td>
|
|
<td>%s</td>
|
|
<td>%s</td>
|
|
</tr>
|
|
<tr class="detail-row" id="%s" hidden><td colspan="%d"><div class="inner">%s</div></td></tr>`,
|
|
template.HTMLEscapeString(t.Input),
|
|
subject,
|
|
reason,
|
|
detailID, detailID, t.LabelCount,
|
|
t.CreatedAt.Format("2006-01-02 15:04"),
|
|
lastCol,
|
|
detailID, totalCols, renderLabelList(labelsByID[t.ID]),
|
|
)
|
|
}
|
|
fmt.Fprint(w, `</table>`)
|
|
}
|
|
|
|
// renderLabelList returns an HTML fragment listing every label linked to a takedown,
|
|
// marking each as active (neg=0 with no later neg=1 row) or negated. Pure string
|
|
// build-up so it can be embedded inside a <td> via fmt.Fprintf.
|
|
func renderLabelList(labels []Label) string {
|
|
if len(labels) == 0 {
|
|
return `<span class="muted">No labels recorded for this takedown.</span>`
|
|
}
|
|
|
|
// Compute which positive labels have been overridden by a later negation row
|
|
// (same URI). Used to badge the "active" vs "negated" state correctly even
|
|
// when the takedown row itself is still marked active.
|
|
negatedURIs := make(map[string]bool, len(labels))
|
|
for _, l := range labels {
|
|
if l.Neg {
|
|
negatedURIs[l.URI] = true
|
|
}
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(`<table class="label-list"><tr><th>State</th><th>URI</th><th>Created</th></tr>`)
|
|
for _, l := range labels {
|
|
var tag string
|
|
switch {
|
|
case l.Neg:
|
|
tag = `<span class="tag tag-neg">negation</span>`
|
|
case negatedURIs[l.URI]:
|
|
tag = `<span class="tag tag-neg">negated</span>`
|
|
default:
|
|
tag = `<span class="tag tag-active">active</span>`
|
|
}
|
|
fmt.Fprintf(&b, `<tr><td>%s</td><td><code>%s</code></td><td>%s</td></tr>`,
|
|
tag,
|
|
template.HTMLEscapeString(l.URI),
|
|
l.Cts.Format("2006-01-02 15:04"),
|
|
)
|
|
}
|
|
b.WriteString(`</table>`)
|
|
return b.String()
|
|
}
|
|
|
|
func (s *Server) handleTakedownForm(w http.ResponseWriter, r *http.Request) {
|
|
msg := r.URL.Query().Get("msg")
|
|
errorMsg := r.URL.Query().Get("error")
|
|
csrf := ""
|
|
if session := SessionFromContext(r.Context()); session != nil {
|
|
csrf = session.CSRFToken
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<!DOCTYPE html>
|
|
<html>
|
|
<head><title>%s Labeler - New Takedown</title>
|
|
<style>
|
|
body{font-family:system-ui;max-width:600px;margin:40px auto;padding:0 20px}
|
|
input[type=text]{width:100%%;padding:8px;margin:8px 0;box-sizing:border-box}
|
|
.btn{padding:10px 20px;background:#dc2626;color:white;border:none;border-radius:4px;cursor:pointer;font-size:1em}
|
|
.success{color:green;margin-bottom:1em}
|
|
.error{color:red;margin-bottom:1em}
|
|
a{color:#2563eb}
|
|
nav{display:flex;gap:16px;margin-bottom:24px}
|
|
.nav-btn{padding:8px 16px;background:#2563eb;color:white;text-decoration:none;border-radius:4px}
|
|
.help{color:#666;font-size:0.9em;margin-top:4px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>New Takedown</h1>
|
|
<nav>
|
|
<a href="/" class="nav-btn">Dashboard</a>
|
|
<a href="/takedown" class="nav-btn">New Takedown</a>
|
|
</nav>`,
|
|
s.config.Labeler.ClientShortName,
|
|
)
|
|
|
|
if msg != "" {
|
|
fmt.Fprintf(w, `<div class="success">%s</div>`, template.HTMLEscapeString(msg))
|
|
}
|
|
if errorMsg != "" {
|
|
fmt.Fprintf(w, `<div class="error">%s</div>`, template.HTMLEscapeString(errorMsg))
|
|
}
|
|
|
|
fmt.Fprintf(w, `
|
|
<form method="POST" action="/takedown">
|
|
%s
|
|
<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>
|
|
</body></html>`, csrfInputHTML(csrf))
|
|
}
|
|
|
|
func (s *Server) handleTakedownSubmit(w http.ResponseWriter, r *http.Request) {
|
|
target := strings.TrimSpace(r.FormValue("target"))
|
|
if target == "" {
|
|
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 {
|
|
http.Redirect(w, r, "/takedown?error="+strings.ReplaceAll(err.Error(), " ", "+"), http.StatusFound)
|
|
return
|
|
}
|
|
|
|
msg := fmt.Sprintf("Takedown #%d issued: %d labels created for %s", result.TakedownID, len(result.Labels), result.DID)
|
|
if result.Repository != "" {
|
|
msg += "/" + result.Repository
|
|
}
|
|
http.Redirect(w, r, "/takedown?msg="+strings.ReplaceAll(msg, " ", "+"), http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleReverse(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
reversedBy := ""
|
|
if session := SessionFromContext(r.Context()); session != nil {
|
|
reversedBy = session.DID
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|