Files
at-container-registry/pkg/labeler/takedown.go
T
2026-05-03 20:49:56 -05:00

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