Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: mod page for cache purge/fetch #21

open opened by pdewey.com targeting main from push-xzurollxuuks
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:hm5f3dnm6jdhrc55qp2npdja/sh.tangled.repo.pull/3mlcakdtsj222
+227 -5
Diff #0
+34
internal/atproto/public_client.go
··· 165 165 return out, nil 166 166 } 167 167 168 + // ListAllRecords paginates through every record in a collection on the user's 169 + // PDS and returns them all, newest-first. Use for moderation tools that need 170 + // the full repo state for a user; for normal feed paths prefer the witness 171 + // cache or a single-page ListRecords. 172 + func (c *PublicClient) ListAllRecords(ctx context.Context, did, collection string) ([]PublicRecordEntry, error) { 173 + const pageSize = 100 174 + const maxPages = 100 // hard ceiling against runaway loops; 10k records per collection is plenty. 175 + 176 + var all []PublicRecordEntry 177 + cursor := "" 178 + for page := 0; page < maxPages; page++ { 179 + records, next, err := c.inner.ListPublicRecords(ctx, did, collection, atp.ListPublicRecordsOpts{ 180 + Limit: pageSize, 181 + Cursor: cursor, 182 + Reverse: true, 183 + }) 184 + if err != nil { 185 + return nil, err 186 + } 187 + for _, r := range records { 188 + all = append(all, PublicRecordEntry{ 189 + URI: r.URI, 190 + CID: r.CID, 191 + Value: r.Value, 192 + }) 193 + } 194 + if next == "" || len(records) == 0 { 195 + return all, nil 196 + } 197 + cursor = next 198 + } 199 + return all, nil 200 + } 201 + 168 202 // GetRecord fetches a single public record from a user's PDS. 169 203 func (c *PublicClient) GetRecord(ctx context.Context, did, collection, rkey string) (*PublicRecordEntry, error) { 170 204 r, err := c.inner.GetPublicRecord(ctx, did, collection, rkey)
+115
internal/handlers/admin.go
··· 842 842 "purgedAt": time.Now().UTC(), 843 843 }) 844 844 } 845 + 846 + // pdsRecord is the per-record shape in the PDS fetch payload. 847 + type pdsRecord struct { 848 + URI string `json:"uri"` 849 + RKey string `json:"rkey"` 850 + CID string `json:"cid"` 851 + Record map[string]any `json:"record"` 852 + } 853 + 854 + // pdsExport is the top-level payload returned by HandleAdminFetchPDSRecords. 855 + type pdsExport struct { 856 + DID string `json:"did"` 857 + Handle string `json:"handle,omitempty"` 858 + FetchedAt time.Time `json:"fetchedAt"` 859 + Source string `json:"source"` 860 + Collections map[string][]pdsRecord `json:"collections"` 861 + } 862 + 863 + // HandleAdminFetchPDSRecords fetches every Arabica record for an account 864 + // directly from the user's PDS and returns it as a single JSON document. 865 + // Accepts `?actor=did:plc:...` or `?actor=handle.example` โ€” handles are 866 + // resolved via the public directory, not the local witness cache, so this 867 + // works even for users who've never appeared on the firehose. 868 + // 869 + // This is the moderator-side counterpart to /_mod/export: where export reads 870 + // the local witness cache, this one reads the canonical PDS state. Useful for 871 + // investigating reports, comparing against the cache, or capturing a snapshot 872 + // before purging. Auth checks are handled by RequireModerator. 873 + func (h *Handler) HandleAdminFetchPDSRecords(w http.ResponseWriter, r *http.Request) { 874 + actor := strings.TrimSpace(r.URL.Query().Get("actor")) 875 + if actor == "" { 876 + http.Error(w, "missing 'actor' query parameter (DID or handle)", http.StatusBadRequest) 877 + return 878 + } 879 + 880 + publicClient := atproto.NewPublicClient() 881 + 882 + var didStr, handle string 883 + if strings.HasPrefix(actor, "did:") { 884 + did, err := syntax.ParseDID(actor) 885 + if err != nil { 886 + http.Error(w, fmt.Sprintf("invalid DID: %v", err), http.StatusBadRequest) 887 + return 888 + } 889 + didStr = did.String() 890 + } else { 891 + resolved, err := publicClient.ResolveHandle(r.Context(), actor) 892 + if err != nil { 893 + log.Warn().Err(err).Str("handle", actor).Msg("PDS fetch: ResolveHandle failed") 894 + http.Error(w, fmt.Sprintf("could not resolve handle %q: %v", actor, err), http.StatusNotFound) 895 + return 896 + } 897 + didStr = resolved 898 + handle = actor 899 + } 900 + 901 + out := pdsExport{ 902 + DID: didStr, 903 + Handle: handle, 904 + FetchedAt: time.Now().UTC(), 905 + Source: "pds", 906 + Collections: make(map[string][]pdsRecord, len(firehose.ArabicaCollections)), 907 + } 908 + 909 + requester, _ := atproto.GetAuthenticatedDID(r.Context()) 910 + 911 + for _, collection := range firehose.ArabicaCollections { 912 + records, err := publicClient.ListAllRecords(r.Context(), didStr, collection) 913 + if err != nil { 914 + // One collection failing shouldn't sink the whole fetch โ€” record an 915 + // empty list and continue. The collection key is preserved so the 916 + // caller can see which slots came up empty. 917 + log.Warn().Err(err). 918 + Str("did", didStr). 919 + Str("collection", collection). 920 + Str("actor", requester). 921 + Msg("PDS fetch: ListAllRecords failed for collection") 922 + out.Collections[collection] = []pdsRecord{} 923 + continue 924 + } 925 + entries := make([]pdsRecord, 0, len(records)) 926 + for _, rec := range records { 927 + rkey := "" 928 + if comp, err := atproto.ResolveATURI(rec.URI); err == nil { 929 + rkey = comp.RKey 930 + } 931 + entries = append(entries, pdsRecord{ 932 + URI: rec.URI, 933 + RKey: rkey, 934 + CID: rec.CID, 935 + Record: rec.Value, 936 + }) 937 + } 938 + out.Collections[collection] = entries 939 + } 940 + 941 + log.Info(). 942 + Str("did", didStr). 943 + Str("handle", handle). 944 + Str("actor", requester). 945 + Msg("PDS fetch: returned records") 946 + 947 + filename := fmt.Sprintf("arabica-pds-%s-%s.json", 948 + strings.ReplaceAll(didStr, ":", "_"), 949 + out.FetchedAt.Format("20060102-150405")) 950 + 951 + w.Header().Set("Content-Type", "application/json") 952 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 953 + 954 + enc := json.NewEncoder(w) 955 + enc.SetIndent("", " ") 956 + if err := enc.Encode(out); err != nil { 957 + log.Error().Err(err).Str("did", didStr).Msg("PDS fetch: encode failed") 958 + } 959 + }
+2
internal/routing/routing.go
··· 200 200 http.HandlerFunc(h.HandleAdminExportDID))) 201 201 mux.Handle("POST /_mod/purge", cop.Handler( 202 202 middleware.RequireAdmin(modSvc, http.HandlerFunc(h.HandleAdminPurgeDID)))) 203 + mux.Handle("GET /_mod/pds-records", middleware.RequireModerator(modSvc, 204 + http.HandlerFunc(h.HandleAdminFetchPDSRecords))) 203 205 204 206 // Static files (must come after specific routes) 205 207 fs := http.FileServer(http.Dir("static"))
+76 -5
internal/web/pages/admin.templ
··· 173 173 </button> 174 174 <button 175 175 type="button" 176 - @click="activeTab = 'export'" 177 - :class="activeTab === 'export' ? 'bg-brown-300 text-brown-900 border-brown-400' : 'bg-brown-100 text-brown-600 border-brown-200 hover:bg-brown-200'" 176 + @click="activeTab = 'cache'" 177 + :class="activeTab === 'cache' ? 'bg-brown-300 text-brown-900 border-brown-400' : 'bg-brown-100 text-brown-600 border-brown-200 hover:bg-brown-200'" 178 178 class="px-3 py-1.5 rounded-lg border font-medium text-sm transition-colors" 179 179 > 180 - Export 180 + Cache 181 181 </button> 182 182 } 183 183 </nav> ··· 378 378 </div> 379 379 </div> 380 380 } 381 - <!-- Export Tab (admin only) --> 381 + <!-- Cache Tab (admin only) --> 382 382 if props.IsAdmin { 383 - <div x-show="activeTab === 'export'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> 383 + <div x-show="activeTab === 'cache'" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="space-y-4"> 384 384 <div class="card card-inner"> 385 385 <h2 class="section-title">Export Witness Cache</h2> 386 386 <p class="text-sm text-brown-600 mb-4"> ··· 411 411 </button> 412 412 </form> 413 413 </div> 414 + <div class="card card-inner"> 415 + <h2 class="section-title">Fetch PDS Records</h2> 416 + <p class="text-sm text-brown-600 mb-4"> 417 + Fetch every Arabica record for a user directly from their PDS, bypassing 418 + the witness cache. Accepts a DID or a handle. Useful for investigating 419 + reports or capturing a snapshot before purging. 420 + </p> 421 + <form 422 + action="/_mod/pds-records" 423 + method="get" 424 + class="flex flex-col gap-3 sm:flex-row sm:items-end" 425 + > 426 + <div class="flex-1"> 427 + <label for="pds-actor" class="block text-sm font-medium text-brown-700 mb-1">DID or handle</label> 428 + <input 429 + id="pds-actor" 430 + type="text" 431 + name="actor" 432 + required 433 + placeholder="did:plc:... or alice.example.com" 434 + class="w-full px-3 py-2 border border-brown-300 rounded-lg bg-white text-brown-900 text-sm font-mono focus:ring-2 focus:ring-amber-500 focus:border-amber-500" 435 + /> 436 + </div> 437 + <button 438 + type="submit" 439 + class="text-sm bg-brown-300 text-brown-900 hover:bg-brown-400 px-4 py-2 rounded font-medium transition-colors" 440 + > 441 + Fetch JSON 442 + </button> 443 + </form> 444 + </div> 445 + <div class="card card-inner border-red-300"> 446 + <h2 class="section-title text-red-900">Purge DID from Witness Cache</h2> 447 + <p class="text-sm text-brown-600 mb-2"> 448 + Remove every record, like, comment, notification, profile entry, and 449 + handle mapping for a DID from the local witness cache. Moderation tables 450 + (reports, audit log, blacklist, labels) are preserved as evidence. 451 + </p> 452 + <p class="text-sm text-red-800 mb-4"> 453 + This is irreversible from the dashboard. Use only when an account has 454 + orphaned its data โ€” e.g. the user's PDS is gone and no 455 + <code class="bg-red-100 px-1 rounded">deleted</code>/<code class="bg-red-100 px-1 rounded">takendown</code> 456 + event will ever arrive on the firehose. 457 + </p> 458 + <form 459 + hx-post="/_mod/purge" 460 + hx-confirm="Permanently purge this DID from the witness cache? This cannot be undone." 461 + hx-swap="innerHTML" 462 + hx-target="#purge-result" 463 + class="flex flex-col gap-3 sm:flex-row sm:items-end" 464 + > 465 + <div class="flex-1"> 466 + <label for="purge-did" class="block text-sm font-medium text-brown-700 mb-1">DID</label> 467 + <input 468 + id="purge-did" 469 + type="text" 470 + name="did" 471 + required 472 + placeholder="did:plc:..." 473 + class="w-full px-3 py-2 border border-red-300 rounded-lg bg-white text-brown-900 text-sm font-mono focus:ring-2 focus:ring-red-500 focus:border-red-500" 474 + /> 475 + </div> 476 + <button 477 + type="submit" 478 + class="text-sm bg-red-700 text-white hover:bg-red-800 px-4 py-2 rounded font-medium transition-colors" 479 + > 480 + Purge DID 481 + </button> 482 + </form> 483 + <div id="purge-result" class="mt-3 text-sm text-brown-700 font-mono"></div> 484 + </div> 414 485 </div> 415 486 } 416 487 </div>

History

1 round 0 comments
sign up or login to add to the discussion
pdewey.com submitted #0
1 commit
expand
feat: mod page for cache purge/fetch
merge conflicts detected
expand
  • internal/handlers/admin.go:842
  • internal/routing/routing.go:200
expand 0 comments