···165165 return out, nil
166166}
167167168168+// ListAllRecords paginates through every record in a collection on the user's
169169+// PDS and returns them all, newest-first. Use for moderation tools that need
170170+// the full repo state for a user; for normal feed paths prefer the witness
171171+// cache or a single-page ListRecords.
172172+func (c *PublicClient) ListAllRecords(ctx context.Context, did, collection string) ([]PublicRecordEntry, error) {
173173+ const pageSize = 100
174174+ const maxPages = 100 // hard ceiling against runaway loops; 10k records per collection is plenty.
175175+176176+ var all []PublicRecordEntry
177177+ cursor := ""
178178+ for page := 0; page < maxPages; page++ {
179179+ records, next, err := c.inner.ListPublicRecords(ctx, did, collection, atp.ListPublicRecordsOpts{
180180+ Limit: pageSize,
181181+ Cursor: cursor,
182182+ Reverse: true,
183183+ })
184184+ if err != nil {
185185+ return nil, err
186186+ }
187187+ for _, r := range records {
188188+ all = append(all, PublicRecordEntry{
189189+ URI: r.URI,
190190+ CID: r.CID,
191191+ Value: r.Value,
192192+ })
193193+ }
194194+ if next == "" || len(records) == 0 {
195195+ return all, nil
196196+ }
197197+ cursor = next
198198+ }
199199+ return all, nil
200200+}
201201+168202// GetRecord fetches a single public record from a user's PDS.
169203func (c *PublicClient) GetRecord(ctx context.Context, did, collection, rkey string) (*PublicRecordEntry, error) {
170204 r, err := c.inner.GetPublicRecord(ctx, did, collection, rkey)
+115
internal/handlers/admin.go
···842842 "purgedAt": time.Now().UTC(),
843843 })
844844}
845845+846846+// pdsRecord is the per-record shape in the PDS fetch payload.
847847+type pdsRecord struct {
848848+ URI string `json:"uri"`
849849+ RKey string `json:"rkey"`
850850+ CID string `json:"cid"`
851851+ Record map[string]any `json:"record"`
852852+}
853853+854854+// pdsExport is the top-level payload returned by HandleAdminFetchPDSRecords.
855855+type pdsExport struct {
856856+ DID string `json:"did"`
857857+ Handle string `json:"handle,omitempty"`
858858+ FetchedAt time.Time `json:"fetchedAt"`
859859+ Source string `json:"source"`
860860+ Collections map[string][]pdsRecord `json:"collections"`
861861+}
862862+863863+// HandleAdminFetchPDSRecords fetches every Arabica record for an account
864864+// directly from the user's PDS and returns it as a single JSON document.
865865+// Accepts `?actor=did:plc:...` or `?actor=handle.example` — handles are
866866+// resolved via the public directory, not the local witness cache, so this
867867+// works even for users who've never appeared on the firehose.
868868+//
869869+// This is the moderator-side counterpart to /_mod/export: where export reads
870870+// the local witness cache, this one reads the canonical PDS state. Useful for
871871+// investigating reports, comparing against the cache, or capturing a snapshot
872872+// before purging. Auth checks are handled by RequireModerator.
873873+func (h *Handler) HandleAdminFetchPDSRecords(w http.ResponseWriter, r *http.Request) {
874874+ actor := strings.TrimSpace(r.URL.Query().Get("actor"))
875875+ if actor == "" {
876876+ http.Error(w, "missing 'actor' query parameter (DID or handle)", http.StatusBadRequest)
877877+ return
878878+ }
879879+880880+ publicClient := atproto.NewPublicClient()
881881+882882+ var didStr, handle string
883883+ if strings.HasPrefix(actor, "did:") {
884884+ did, err := syntax.ParseDID(actor)
885885+ if err != nil {
886886+ http.Error(w, fmt.Sprintf("invalid DID: %v", err), http.StatusBadRequest)
887887+ return
888888+ }
889889+ didStr = did.String()
890890+ } else {
891891+ resolved, err := publicClient.ResolveHandle(r.Context(), actor)
892892+ if err != nil {
893893+ log.Warn().Err(err).Str("handle", actor).Msg("PDS fetch: ResolveHandle failed")
894894+ http.Error(w, fmt.Sprintf("could not resolve handle %q: %v", actor, err), http.StatusNotFound)
895895+ return
896896+ }
897897+ didStr = resolved
898898+ handle = actor
899899+ }
900900+901901+ out := pdsExport{
902902+ DID: didStr,
903903+ Handle: handle,
904904+ FetchedAt: time.Now().UTC(),
905905+ Source: "pds",
906906+ Collections: make(map[string][]pdsRecord, len(firehose.ArabicaCollections)),
907907+ }
908908+909909+ requester, _ := atproto.GetAuthenticatedDID(r.Context())
910910+911911+ for _, collection := range firehose.ArabicaCollections {
912912+ records, err := publicClient.ListAllRecords(r.Context(), didStr, collection)
913913+ if err != nil {
914914+ // One collection failing shouldn't sink the whole fetch — record an
915915+ // empty list and continue. The collection key is preserved so the
916916+ // caller can see which slots came up empty.
917917+ log.Warn().Err(err).
918918+ Str("did", didStr).
919919+ Str("collection", collection).
920920+ Str("actor", requester).
921921+ Msg("PDS fetch: ListAllRecords failed for collection")
922922+ out.Collections[collection] = []pdsRecord{}
923923+ continue
924924+ }
925925+ entries := make([]pdsRecord, 0, len(records))
926926+ for _, rec := range records {
927927+ rkey := ""
928928+ if comp, err := atproto.ResolveATURI(rec.URI); err == nil {
929929+ rkey = comp.RKey
930930+ }
931931+ entries = append(entries, pdsRecord{
932932+ URI: rec.URI,
933933+ RKey: rkey,
934934+ CID: rec.CID,
935935+ Record: rec.Value,
936936+ })
937937+ }
938938+ out.Collections[collection] = entries
939939+ }
940940+941941+ log.Info().
942942+ Str("did", didStr).
943943+ Str("handle", handle).
944944+ Str("actor", requester).
945945+ Msg("PDS fetch: returned records")
946946+947947+ filename := fmt.Sprintf("arabica-pds-%s-%s.json",
948948+ strings.ReplaceAll(didStr, ":", "_"),
949949+ out.FetchedAt.Format("20060102-150405"))
950950+951951+ w.Header().Set("Content-Type", "application/json")
952952+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
953953+954954+ enc := json.NewEncoder(w)
955955+ enc.SetIndent("", " ")
956956+ if err := enc.Encode(out); err != nil {
957957+ log.Error().Err(err).Str("did", didStr).Msg("PDS fetch: encode failed")
958958+ }
959959+}
+2
internal/routing/routing.go
···200200 http.HandlerFunc(h.HandleAdminExportDID)))
201201 mux.Handle("POST /_mod/purge", cop.Handler(
202202 middleware.RequireAdmin(modSvc, http.HandlerFunc(h.HandleAdminPurgeDID))))
203203+ mux.Handle("GET /_mod/pds-records", middleware.RequireModerator(modSvc,
204204+ http.HandlerFunc(h.HandleAdminFetchPDSRecords)))
203205204206 // Static files (must come after specific routes)
205207 fs := http.FileServer(http.Dir("static"))
+76-5
internal/web/pages/admin.templ
···173173 </button>
174174 <button
175175 type="button"
176176- @click="activeTab = 'export'"
177177- :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'"
176176+ @click="activeTab = 'cache'"
177177+ :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'"
178178 class="px-3 py-1.5 rounded-lg border font-medium text-sm transition-colors"
179179 >
180180- Export
180180+ Cache
181181 </button>
182182 }
183183 </nav>
···378378 </div>
379379 </div>
380380 }
381381- <!-- Export Tab (admin only) -->
381381+ <!-- Cache Tab (admin only) -->
382382 if props.IsAdmin {
383383- <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">
383383+ <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">
384384 <div class="card card-inner">
385385 <h2 class="section-title">Export Witness Cache</h2>
386386 <p class="text-sm text-brown-600 mb-4">
···410410 Export JSON
411411 </button>
412412 </form>
413413+ </div>
414414+ <div class="card card-inner">
415415+ <h2 class="section-title">Fetch PDS Records</h2>
416416+ <p class="text-sm text-brown-600 mb-4">
417417+ Fetch every Arabica record for a user directly from their PDS, bypassing
418418+ the witness cache. Accepts a DID or a handle. Useful for investigating
419419+ reports or capturing a snapshot before purging.
420420+ </p>
421421+ <form
422422+ action="/_mod/pds-records"
423423+ method="get"
424424+ class="flex flex-col gap-3 sm:flex-row sm:items-end"
425425+ >
426426+ <div class="flex-1">
427427+ <label for="pds-actor" class="block text-sm font-medium text-brown-700 mb-1">DID or handle</label>
428428+ <input
429429+ id="pds-actor"
430430+ type="text"
431431+ name="actor"
432432+ required
433433+ placeholder="did:plc:... or alice.example.com"
434434+ 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"
435435+ />
436436+ </div>
437437+ <button
438438+ type="submit"
439439+ class="text-sm bg-brown-300 text-brown-900 hover:bg-brown-400 px-4 py-2 rounded font-medium transition-colors"
440440+ >
441441+ Fetch JSON
442442+ </button>
443443+ </form>
444444+ </div>
445445+ <div class="card card-inner border-red-300">
446446+ <h2 class="section-title text-red-900">Purge DID from Witness Cache</h2>
447447+ <p class="text-sm text-brown-600 mb-2">
448448+ Remove every record, like, comment, notification, profile entry, and
449449+ handle mapping for a DID from the local witness cache. Moderation tables
450450+ (reports, audit log, blacklist, labels) are preserved as evidence.
451451+ </p>
452452+ <p class="text-sm text-red-800 mb-4">
453453+ This is irreversible from the dashboard. Use only when an account has
454454+ orphaned its data — e.g. the user's PDS is gone and no
455455+ <code class="bg-red-100 px-1 rounded">deleted</code>/<code class="bg-red-100 px-1 rounded">takendown</code>
456456+ event will ever arrive on the firehose.
457457+ </p>
458458+ <form
459459+ hx-post="/_mod/purge"
460460+ hx-confirm="Permanently purge this DID from the witness cache? This cannot be undone."
461461+ hx-swap="innerHTML"
462462+ hx-target="#purge-result"
463463+ class="flex flex-col gap-3 sm:flex-row sm:items-end"
464464+ >
465465+ <div class="flex-1">
466466+ <label for="purge-did" class="block text-sm font-medium text-brown-700 mb-1">DID</label>
467467+ <input
468468+ id="purge-did"
469469+ type="text"
470470+ name="did"
471471+ required
472472+ placeholder="did:plc:..."
473473+ 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"
474474+ />
475475+ </div>
476476+ <button
477477+ type="submit"
478478+ class="text-sm bg-red-700 text-white hover:bg-red-800 px-4 py-2 rounded font-medium transition-colors"
479479+ >
480480+ Purge DID
481481+ </button>
482482+ </form>
483483+ <div id="purge-result" class="mt-3 text-sm text-brown-700 font-mono"></div>
413484 </div>
414485 </div>
415486 }