+227
-5
Diff
round #0
+34
internal/atproto/public_client.go
+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
+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
+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
+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
pdewey.com
submitted
#0
1 commit
expand
collapse
feat: mod page for cache purge/fetch
merge conflicts detected
expand
collapse
expand
collapse
- internal/handlers/admin.go:842
- internal/routing/routing.go:200