mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
419 lines
12 KiB
Go
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)
|
|
}
|