···103103 }
104104 return user
105105}
106106+107107+// WithUser returns a new request with the user set in the context.
108108+// This is primarily useful for testing.
109109+func WithUser(r *http.Request, user *db.User) *http.Request {
110110+ ctx := context.WithValue(r.Context(), userKey, user)
111111+ return r.WithContext(ctx)
112112+}
···687687func (c *Client) PDSEndpoint() string {
688688 return c.pdsEndpoint
689689}
690690+691691+// ListRecordsWithCursor lists records in a collection with cursor-based pagination.
692692+// Returns records, next cursor (empty if no more), and error.
693693+func (c *Client) ListRecordsWithCursor(ctx context.Context, collection string, limit int, cursor string) ([]Record, string, error) {
694694+ url := fmt.Sprintf("%s%s?repo=%s&collection=%s&limit=%d",
695695+ c.pdsEndpoint, RepoListRecords, c.did, collection, limit)
696696+697697+ if cursor != "" {
698698+ url += "&cursor=" + cursor
699699+ }
700700+701701+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
702702+ if err != nil {
703703+ return nil, "", err
704704+ }
705705+706706+ if c.accessToken != "" {
707707+ req.Header.Set("Authorization", "Bearer "+c.accessToken)
708708+ }
709709+710710+ resp, err := c.httpClient.Do(req)
711711+ if err != nil {
712712+ return nil, "", fmt.Errorf("failed to list records: %w", err)
713713+ }
714714+ defer resp.Body.Close()
715715+716716+ if resp.StatusCode != http.StatusOK {
717717+ bodyBytes, _ := io.ReadAll(resp.Body)
718718+ return nil, "", fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes))
719719+ }
720720+721721+ var result struct {
722722+ Records []Record `json:"records"`
723723+ Cursor string `json:"cursor,omitempty"`
724724+ }
725725+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
726726+ return nil, "", fmt.Errorf("failed to decode response: %w", err)
727727+ }
728728+729729+ return result.Records, result.Cursor, nil
730730+}
731731+732732+// DeleteAllRecordsInCollection deletes all records in a collection.
733733+// Returns the number of records deleted.
734734+// This is used for GDPR account deletion to remove all user records from a collection.
735735+func (c *Client) DeleteAllRecordsInCollection(ctx context.Context, collection string) (int, error) {
736736+ deleted := 0
737737+ cursor := ""
738738+739739+ for {
740740+ // List records with pagination
741741+ records, nextCursor, err := c.ListRecordsWithCursor(ctx, collection, 100, cursor)
742742+ if err != nil {
743743+ return deleted, fmt.Errorf("failed to list records: %w", err)
744744+ }
745745+746746+ for _, rec := range records {
747747+ // Extract rkey from URI (at://{did}/{collection}/{rkey})
748748+ rkey := extractRkeyFromURI(rec.URI)
749749+ if rkey == "" {
750750+ continue
751751+ }
752752+753753+ err := c.DeleteRecord(ctx, collection, rkey)
754754+ if err != nil {
755755+ // Log but continue with other records
756756+ continue
757757+ }
758758+ deleted++
759759+ }
760760+761761+ if nextCursor == "" {
762762+ break
763763+ }
764764+ cursor = nextCursor
765765+ }
766766+767767+ return deleted, nil
768768+}
769769+770770+// extractRkeyFromURI extracts the rkey from an AT URI (at://{did}/{collection}/{rkey})
771771+func extractRkeyFromURI(uri string) string {
772772+ // Format: at://did:plc:xxx/io.atcr.manifest/abc123
773773+ parts := strings.Split(uri, "/")
774774+ if len(parts) < 5 {
775775+ return ""
776776+ }
777777+ return parts[len(parts)-1]
778778+}
+192
pkg/hold/pds/delete.go
···11+package pds
22+33+import (
44+ "context"
55+ "fmt"
66+ "log/slog"
77+88+ "atcr.io/pkg/atproto"
99+)
1010+1111+// UserDeleteResult contains the results of deleting a user's data from the hold
1212+type UserDeleteResult struct {
1313+ CrewDeleted bool `json:"crew_deleted"`
1414+ LayersDeleted int `json:"layers_deleted"`
1515+ StatsDeleted int `json:"stats_deleted"`
1616+}
1717+1818+// DeleteUserData deletes all data for a user from the hold's PDS.
1919+// This removes:
2020+// - Crew record (if user is a crew member)
2121+// - Layer records (where userDid matches)
2222+// - Stats records (where ownerDid matches)
2323+//
2424+// NOTE: This does NOT delete the captain record if the user is the hold owner.
2525+// NOTE: This does NOT delete actual blob data from S3 - only the PDS records.
2626+func (p *HoldPDS) DeleteUserData(ctx context.Context, userDID string) (*UserDeleteResult, error) {
2727+ result := &UserDeleteResult{}
2828+2929+ slog.Info("Deleting user data from hold",
3030+ "user_did", userDID,
3131+ "hold_did", p.DID())
3232+3333+ // 1. Delete crew record (if exists)
3434+ crewDeleted, err := p.deleteCrewRecord(ctx, userDID)
3535+ if err != nil {
3636+ slog.Warn("Failed to delete crew record",
3737+ "user_did", userDID,
3838+ "error", err)
3939+ // Continue with other deletions
4040+ }
4141+ result.CrewDeleted = crewDeleted
4242+4343+ // 2. Delete layer records
4444+ layersDeleted, err := p.deleteLayerRecords(ctx, userDID)
4545+ if err != nil {
4646+ slog.Warn("Failed to delete layer records",
4747+ "user_did", userDID,
4848+ "error", err)
4949+ // Continue with other deletions
5050+ }
5151+ result.LayersDeleted = layersDeleted
5252+5353+ // 3. Delete stats records
5454+ statsDeleted, err := p.deleteStatsRecords(ctx, userDID)
5555+ if err != nil {
5656+ slog.Warn("Failed to delete stats records",
5757+ "user_did", userDID,
5858+ "error", err)
5959+ // Continue with other deletions
6060+ }
6161+ result.StatsDeleted = statsDeleted
6262+6363+ slog.Info("User data deletion complete",
6464+ "user_did", userDID,
6565+ "hold_did", p.DID(),
6666+ "crew_deleted", result.CrewDeleted,
6767+ "layers_deleted", result.LayersDeleted,
6868+ "stats_deleted", result.StatsDeleted)
6969+7070+ return result, nil
7171+}
7272+7373+// deleteCrewRecord removes a user's crew record from the hold
7474+func (p *HoldPDS) deleteCrewRecord(ctx context.Context, userDID string) (bool, error) {
7575+ // Check if user has a crew record
7676+ _, _, err := p.GetCrewMemberByDID(ctx, userDID)
7777+ if err != nil {
7878+ // No crew record found
7979+ return false, nil
8080+ }
8181+8282+ // Delete the crew record
8383+ err = p.RemoveCrewMemberByDID(ctx, userDID)
8484+ if err != nil {
8585+ return false, fmt.Errorf("failed to remove crew member: %w", err)
8686+ }
8787+8888+ slog.Debug("Deleted crew record", "user_did", userDID)
8989+ return true, nil
9090+}
9191+9292+// deleteLayerRecords removes all layer records for a user
9393+func (p *HoldPDS) deleteLayerRecords(ctx context.Context, userDID string) (int, error) {
9494+ if p.recordsIndex == nil {
9595+ return 0, fmt.Errorf("records index not available")
9696+ }
9797+9898+ deleted := 0
9999+ cursor := ""
100100+ batchSize := 100
101101+102102+ for {
103103+ // Get layer records for this user via the DID index
104104+ records, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.LayerCollection, userDID, batchSize, cursor)
105105+ if err != nil {
106106+ return deleted, fmt.Errorf("failed to list layer records: %w", err)
107107+ }
108108+109109+ for _, rec := range records {
110110+ // Delete from repo (MST)
111111+ err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.LayerCollection, rec.Rkey)
112112+ if err != nil {
113113+ slog.Warn("Failed to delete layer record from repo",
114114+ "rkey", rec.Rkey,
115115+ "error", err)
116116+ continue
117117+ }
118118+119119+ // Delete from index
120120+ err = p.recordsIndex.DeleteRecord(atproto.LayerCollection, rec.Rkey)
121121+ if err != nil {
122122+ slog.Warn("Failed to delete layer record from index",
123123+ "rkey", rec.Rkey,
124124+ "error", err)
125125+ }
126126+127127+ deleted++
128128+ }
129129+130130+ if nextCursor == "" {
131131+ break
132132+ }
133133+ cursor = nextCursor
134134+ }
135135+136136+ if deleted > 0 {
137137+ slog.Debug("Deleted layer records", "user_did", userDID, "count", deleted)
138138+ }
139139+140140+ return deleted, nil
141141+}
142142+143143+// deleteStatsRecords removes all stats records for a user
144144+func (p *HoldPDS) deleteStatsRecords(ctx context.Context, userDID string) (int, error) {
145145+ if p.recordsIndex == nil {
146146+ return 0, fmt.Errorf("records index not available")
147147+ }
148148+149149+ deleted := 0
150150+ cursor := ""
151151+ batchSize := 100
152152+153153+ for {
154154+ // Get stats records for this user via the DID index
155155+ records, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.StatsCollection, userDID, batchSize, cursor)
156156+ if err != nil {
157157+ return deleted, fmt.Errorf("failed to list stats records: %w", err)
158158+ }
159159+160160+ for _, rec := range records {
161161+ // Delete from repo (MST)
162162+ err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.StatsCollection, rec.Rkey)
163163+ if err != nil {
164164+ slog.Warn("Failed to delete stats record from repo",
165165+ "rkey", rec.Rkey,
166166+ "error", err)
167167+ continue
168168+ }
169169+170170+ // Delete from index
171171+ err = p.recordsIndex.DeleteRecord(atproto.StatsCollection, rec.Rkey)
172172+ if err != nil {
173173+ slog.Warn("Failed to delete stats record from index",
174174+ "rkey", rec.Rkey,
175175+ "error", err)
176176+ }
177177+178178+ deleted++
179179+ }
180180+181181+ if nextCursor == "" {
182182+ break
183183+ }
184184+ cursor = nextCursor
185185+ }
186186+187187+ if deleted > 0 {
188188+ slog.Debug("Deleted stats records", "user_did", userDID, "count", deleted)
189189+ }
190190+191191+ return deleted, nil
192192+}
+78-1
pkg/hold/pds/xrpc.go
···195195 r.Group(func(r chi.Router) {
196196 r.Use(h.requireAuth)
197197 r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew)
198198- // GDPR data export endpoint (TODO: implement)
198198+ // GDPR data export endpoint
199199 r.Get("/xrpc/io.atcr.hold.exportUserData", h.HandleExportUserData)
200200+ // GDPR data deletion endpoint
201201+ r.Delete("/xrpc/io.atcr.hold.deleteUserData", h.HandleDeleteUserData)
200202 })
201203202204 // Public quota endpoint (no auth - quota is per-user, just needs userDid param)
···1630163216311633 render.JSON(w, r, export)
16321634}
16351635+16361636+// HoldUserDeleteResponse represents the result of GDPR data deletion
16371637+type HoldUserDeleteResponse struct {
16381638+ Success bool `json:"success"`
16391639+ CrewDeleted bool `json:"crew_deleted"`
16401640+ LayersDeleted int `json:"layers_deleted"`
16411641+ StatsDeleted int `json:"stats_deleted"`
16421642+}
16431643+16441644+// HandleDeleteUserData handles GDPR data deletion requests for a specific user.
16451645+// This endpoint deletes all records stored on this hold's PDS that reference
16461646+// the authenticated user's DID.
16471647+//
16481648+// Deletes:
16491649+// - io.atcr.hold.crew record for the DID (if exists, and user is NOT captain)
16501650+// - io.atcr.hold.layer records where userDid matches
16511651+// - io.atcr.hold.stats records where ownerDid matches
16521652+//
16531653+// NOTE: This does NOT delete the captain record if the user is the hold owner.
16541654+// NOTE: This does NOT delete actual blob data from S3 - only the PDS records.
16551655+//
16561656+// Authentication: Requires valid service token from user's PDS
16571657+func (h *XRPCHandler) HandleDeleteUserData(w http.ResponseWriter, r *http.Request) {
16581658+ // Get authenticated user from context
16591659+ user := getUserFromContext(r)
16601660+ if user == nil {
16611661+ http.Error(w, "authentication required", http.StatusUnauthorized)
16621662+ return
16631663+ }
16641664+16651665+ slog.Info("GDPR data deletion requested",
16661666+ "requester_did", user.DID,
16671667+ "hold_did", h.pds.DID())
16681668+16691669+ // Check if user is captain - if so, skip crew deletion but continue with layer/stats
16701670+ isCaptain := false
16711671+ _, captain, err := h.pds.GetCaptainRecord(r.Context())
16721672+ if err == nil && captain != nil && captain.Owner == user.DID {
16731673+ isCaptain = true
16741674+ slog.Info("User is captain of this hold, will not delete captain record",
16751675+ "user_did", user.DID,
16761676+ "hold_did", h.pds.DID())
16771677+ }
16781678+16791679+ // Delete user data from hold
16801680+ result, err := h.pds.DeleteUserData(r.Context(), user.DID)
16811681+ if err != nil {
16821682+ slog.Error("Failed to delete user data",
16831683+ "user_did", user.DID,
16841684+ "hold_did", h.pds.DID(),
16851685+ "error", err)
16861686+ http.Error(w, fmt.Sprintf("failed to delete user data: %v", err), http.StatusInternalServerError)
16871687+ return
16881688+ }
16891689+16901690+ // If user is captain, they shouldn't have a crew record deleted (they're the owner)
16911691+ // The DeleteUserData function handles crew deletion, but we report it appropriately
16921692+ if isCaptain {
16931693+ result.CrewDeleted = false
16941694+ }
16951695+16961696+ slog.Info("GDPR data deletion completed",
16971697+ "user_did", user.DID,
16981698+ "hold_did", h.pds.DID(),
16991699+ "crew_deleted", result.CrewDeleted,
17001700+ "layers_deleted", result.LayersDeleted,
17011701+ "stats_deleted", result.StatsDeleted)
17021702+17031703+ render.JSON(w, r, HoldUserDeleteResponse{
17041704+ Success: true,
17051705+ CrewDeleted: result.CrewDeleted,
17061706+ LayersDeleted: result.LayersDeleted,
17071707+ StatsDeleted: result.StatsDeleted,
17081708+ })
17091709+}