Files

419 lines
12 KiB
Go

package labeler
import (
"context"
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"strings"
"time"
"atcr.io/pkg/atproto"
)
// TakedownInput represents parsed takedown input.
type TakedownInput struct {
DID string
Handle string
Repository string // empty = user-level takedown
}
// ParseTakedownInput parses various input formats into a TakedownInput.
// Supported formats:
// - atcr.io/r/handle/repo
// - handle/repo
// - at://did:plc:xyz/io.atcr.repo.page/repo
// - at://did:plc:xyz (user-level)
// - handle (user-level)
// - did:plc:xyz (user-level)
func ParseTakedownInput(ctx context.Context, input string) (*TakedownInput, error) {
input = strings.TrimSpace(input)
// AT URI format
if strings.HasPrefix(input, "at://") {
return parseATURI(ctx, input)
}
// Strip URL prefix if present
input = strings.TrimPrefix(input, "https://")
input = strings.TrimPrefix(input, "http://")
// Remove atcr.io/r/ or similar prefix
for _, prefix := range []string{"atcr.io/r/", "localhost/r/"} {
if strings.HasPrefix(input, prefix) {
input = strings.TrimPrefix(input, prefix)
break
}
}
// Also handle custom domains: anything ending in /r/
if idx := strings.Index(input, "/r/"); idx >= 0 {
input = input[idx+3:]
}
// Now input should be "handle/repo" or "handle" or "did:xxx"
parts := strings.SplitN(input, "/", 2)
identifier := parts[0]
var repo string
if len(parts) > 1 {
repo = parts[1]
repo = strings.TrimSuffix(repo, "/")
}
did, handle, err := resolveIdentifier(ctx, identifier)
if err != nil {
return nil, err
}
return &TakedownInput{
DID: did,
Handle: handle,
Repository: repo,
}, nil
}
func parseATURI(ctx context.Context, uri string) (*TakedownInput, error) {
// at://did:plc:xyz/collection/rkey
trimmed := strings.TrimPrefix(uri, "at://")
parts := strings.SplitN(trimmed, "/", 3)
did := parts[0]
if !strings.HasPrefix(did, "did:") {
// It's a handle
resolvedDID, handle, err := resolveIdentifier(ctx, did)
if err != nil {
return nil, err
}
did = resolvedDID
if len(parts) >= 3 {
return &TakedownInput{DID: did, Handle: handle, Repository: parts[2]}, nil
}
return &TakedownInput{DID: did, Handle: handle}, nil
}
// Resolve handle from DID
_, handle, _, _ := atproto.ResolveIdentity(ctx, did)
if len(parts) < 3 {
// User-level takedown
return &TakedownInput{DID: did, Handle: handle}, nil
}
// Extract repository from rkey (third part)
repo := parts[2]
return &TakedownInput{DID: did, Handle: handle, Repository: repo}, 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 {
DID string
Handle string
Repository string
Labels []Label
UserLevel bool
}
// ExecuteTakedown creates takedown labels for a repo or user.
func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*TakedownResult, error) {
src := s.config.DID()
now := time.Now().UTC()
result := &TakedownResult{
DID: input.DID,
Handle: input.Handle,
Repository: input.Repository,
UserLevel: input.Repository == "",
}
if input.Repository == "" {
// User-level takedown
label := &Label{
Src: src,
URI: "at://" + input.DID,
Val: "!takedown",
Cts: now,
SubjectDID: input.DID,
SubjectRepo: "",
}
if _, err := CreateLabel(s.db, label); err != nil {
return nil, fmt.Errorf("failed to create user-level label: %w", err)
}
result.Labels = append(result.Labels, *label)
slog.Info("Created user-level takedown", "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)
if err != nil {
// Even if PDS discovery fails, create a repo-level summary label
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,
}
if _, err := CreateLabel(s.db, summaryLabel); err != nil {
return nil, fmt.Errorf("failed to create summary label: %w", err)
}
result.Labels = append(result.Labels, *summaryLabel)
slog.Info("Created repo-level takedown",
"did", input.DID,
"handle", input.Handle,
"repository", input.Repository,
"label_count", len(result.Labels),
)
return result, 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) {
_, _, 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,
}
if _, err := CreateLabel(s.db, label); err != nil {
slog.Warn("Failed to create label", "uri", uri, "error", err)
continue
}
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) {
labels, total, err := ListActiveTakedowns(s.db, 50, 0)
if err != nil {
http.Error(w, "Failed to list takedowns", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head><title>%s Labeler</title>
<style>
body{font-family:system-ui;max-width:900px;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{background:#f5f5f5}
.badge{background:#dc2626;color:white;padding:2px 8px;border-radius:4px;font-size:0.85em}
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}
</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.Server.ClientShortName,
s.config.Server.ClientShortName,
total,
)
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"><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"),
template.HTMLEscapeString(l.SubjectDID),
template.HTMLEscapeString(l.SubjectRepo),
)
}
fmt.Fprint(w, `</table>`)
}
fmt.Fprint(w, `</body></html>`)
}
func (s *Server) handleTakedownForm(w http.ResponseWriter, r *http.Request) {
msg := r.URL.Query().Get("msg")
errorMsg := r.URL.Query().Get("error")
w.Header().Set("Content-Type", "text/html")
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.Server.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.Fprint(w, `
<form method="POST" action="/takedown">
<label for="target"><strong>Target</strong></label>
<input type="text" id="target" name="target" placeholder="atcr.io/r/handle/repo, at://did/collection/rkey, or handle" required>
<p class="help">Accepts repo URLs, AT URIs, handles, or DIDs. Omit the repo for a user-level takedown.</p>
<br>
<button type="submit" class="btn" onclick="return confirm('Issue takedown? This will suppress the content immediately.')">Issue Takedown</button>
</form>
</body></html>`)
}
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
}
input, err := ParseTakedownInput(r.Context(), target)
if err != nil {
http.Redirect(w, r, "/takedown?error="+strings.ReplaceAll(err.Error(), " ", "+"), http.StatusFound)
return
}
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 issued: %d labels created for %s", 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) {
did := strings.TrimSpace(r.FormValue("did"))
repo := strings.TrimSpace(r.FormValue("repo"))
if did == "" {
http.Redirect(w, r, "/?error=DID+is+required", http.StatusFound)
return
}
src := s.config.DID()
var err error
if repo == "" {
err = NegateUserLabels(s.db, src, did)
} else {
err = NegateRepoLabels(s.db, src, did, repo)
}
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)
return
}
slog.Info("Reversed takedown", "did", did, "repo", repo)
http.Redirect(w, r, "/", http.StatusFound)
}