Files
at-container-registry/pkg/hold/pds/delete.go

376 lines
9.4 KiB
Go

package pds
import (
"bytes"
"context"
"fmt"
"log/slog"
"atcr.io/pkg/atproto"
bsky "github.com/bluesky-social/indigo/api/bsky"
)
// UserDeleteResult contains the results of deleting a user's data from the hold
type UserDeleteResult struct {
CrewDeleted bool `json:"crew_deleted"`
LayersDeleted int `json:"layers_deleted"`
StatsDeleted int `json:"stats_deleted"`
PostsDeleted int `json:"posts_deleted"`
}
// DeleteUserData deletes all data for a user from the hold's PDS.
// This removes:
// - Crew record (if user is a crew member)
// - Layer records (where userDid matches)
// - Stats records (where ownerDid matches)
// - Bluesky posts that mention the user (for GDPR compliance)
//
// NOTE: This does NOT delete the captain record if the user is the hold owner.
// NOTE: This does NOT delete actual blob data from S3 - only the PDS records.
func (p *HoldPDS) DeleteUserData(ctx context.Context, userDID string) (*UserDeleteResult, error) {
result := &UserDeleteResult{}
slog.Info("Deleting user data from hold",
"user_did", userDID,
"hold_did", p.DID())
// 1. Delete crew record (if exists)
crewDeleted, err := p.deleteCrewRecord(ctx, userDID)
if err != nil {
slog.Warn("Failed to delete crew record",
"user_did", userDID,
"error", err)
// Continue with other deletions
}
result.CrewDeleted = crewDeleted
// 2. Delete layer records
layersDeleted, err := p.deleteLayerRecords(ctx, userDID)
if err != nil {
slog.Warn("Failed to delete layer records",
"user_did", userDID,
"error", err)
// Continue with other deletions
}
result.LayersDeleted = layersDeleted
// 3. Delete stats records
statsDeleted, err := p.deleteStatsRecords(ctx, userDID)
if err != nil {
slog.Warn("Failed to delete stats records",
"user_did", userDID,
"error", err)
// Continue with other deletions
}
result.StatsDeleted = statsDeleted
// 4. Delete Bluesky posts that mention this user (GDPR compliance)
postsDeleted, err := p.deleteBlueskyPosts(ctx, userDID)
if err != nil {
slog.Warn("Failed to delete bluesky posts",
"user_did", userDID,
"error", err)
// Continue - this is best-effort
}
result.PostsDeleted = postsDeleted
slog.Info("User data deletion complete",
"user_did", userDID,
"hold_did", p.DID(),
"crew_deleted", result.CrewDeleted,
"layers_deleted", result.LayersDeleted,
"stats_deleted", result.StatsDeleted,
"posts_deleted", result.PostsDeleted)
return result, nil
}
// deleteCrewRecord removes a user's crew record from the hold
func (p *HoldPDS) deleteCrewRecord(ctx context.Context, userDID string) (bool, error) {
// Check if user has a crew record
_, _, err := p.GetCrewMemberByDID(ctx, userDID)
if err != nil {
// No crew record found
return false, nil
}
// Delete the crew record
err = p.RemoveCrewMemberByDID(ctx, userDID)
if err != nil {
return false, fmt.Errorf("failed to remove crew member: %w", err)
}
slog.Debug("Deleted crew record", "user_did", userDID)
return true, nil
}
// deleteLayerRecords removes all layer records for a user
func (p *HoldPDS) deleteLayerRecords(ctx context.Context, userDID string) (int, error) {
if p.recordsIndex == nil {
return 0, fmt.Errorf("records index not available")
}
deleted := 0
cursor := ""
batchSize := 100
for {
// Get layer records for this user via the DID index
records, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.LayerCollection, userDID, batchSize, cursor)
if err != nil {
return deleted, fmt.Errorf("failed to list layer records: %w", err)
}
for _, rec := range records {
// Delete from repo (MST)
err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.LayerCollection, rec.Rkey)
if err != nil {
slog.Warn("Failed to delete layer record from repo",
"rkey", rec.Rkey,
"error", err)
continue
}
// Delete from index
err = p.recordsIndex.DeleteRecord(atproto.LayerCollection, rec.Rkey)
if err != nil {
slog.Warn("Failed to delete layer record from index",
"rkey", rec.Rkey,
"error", err)
}
deleted++
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
if deleted > 0 {
slog.Debug("Deleted layer records", "user_did", userDID, "count", deleted)
}
return deleted, nil
}
// deleteStatsRecords removes all stats records for a user
func (p *HoldPDS) deleteStatsRecords(ctx context.Context, userDID string) (int, error) {
if p.recordsIndex == nil {
return 0, fmt.Errorf("records index not available")
}
deleted := 0
cursor := ""
batchSize := 100
for {
// Get stats records for this user via the DID index
records, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.StatsCollection, userDID, batchSize, cursor)
if err != nil {
return deleted, fmt.Errorf("failed to list stats records: %w", err)
}
for _, rec := range records {
// Delete from repo (MST)
err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.StatsCollection, rec.Rkey)
if err != nil {
slog.Warn("Failed to delete stats record from repo",
"rkey", rec.Rkey,
"error", err)
continue
}
// Delete from index
err = p.recordsIndex.DeleteRecord(atproto.StatsCollection, rec.Rkey)
if err != nil {
slog.Warn("Failed to delete stats record from index",
"rkey", rec.Rkey,
"error", err)
}
deleted++
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
if deleted > 0 {
slog.Debug("Deleted stats records", "user_did", userDID, "count", deleted)
}
return deleted, nil
}
// deleteBlueskyPosts removes all Bluesky posts that mention a user's DID
// Posts store mentions in facets: Facets[].Features[].RichtextFacet_Mention.Did
func (p *HoldPDS) deleteBlueskyPosts(ctx context.Context, userDID string) (int, error) {
if p.recordsIndex == nil {
return 0, fmt.Errorf("records index not available")
}
deleted := 0
cursor := ""
batchSize := 100
for {
// Get all Bluesky posts
records, nextCursor, err := p.recordsIndex.ListRecords(atproto.BskyPostCollection, batchSize, cursor, false)
if err != nil {
return deleted, fmt.Errorf("failed to list bluesky posts: %w", err)
}
for _, rec := range records {
// Get the record bytes to check the facets
recordPath := rec.Collection + "/" + rec.Rkey
_, recBytes, err := p.GetRecordBytes(ctx, recordPath)
if err != nil {
slog.Warn("Failed to get post record bytes",
"rkey", rec.Rkey,
"error", err)
continue
}
if recBytes == nil {
continue
}
// Parse as FeedPost to check facets
var post bsky.FeedPost
if err := post.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
slog.Warn("Failed to unmarshal post record",
"rkey", rec.Rkey,
"error", err)
continue
}
// Check if any facet mentions this user's DID
if !postMentionsUser(&post, userDID) {
continue
}
// Delete from repo (MST)
err = p.repomgr.DeleteRecord(ctx, p.uid, atproto.BskyPostCollection, rec.Rkey)
if err != nil {
slog.Warn("Failed to delete bluesky post from repo",
"rkey", rec.Rkey,
"error", err)
continue
}
// Delete from index
err = p.recordsIndex.DeleteRecord(atproto.BskyPostCollection, rec.Rkey)
if err != nil {
slog.Warn("Failed to delete bluesky post from index",
"rkey", rec.Rkey,
"error", err)
}
deleted++
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
if deleted > 0 {
slog.Debug("Deleted bluesky posts mentioning user", "user_did", userDID, "count", deleted)
}
return deleted, nil
}
// postMentionsUser checks if a post's facets contain a mention of the given DID
func postMentionsUser(post *bsky.FeedPost, userDID string) bool {
if post.Facets == nil {
return false
}
for _, facet := range post.Facets {
if facet.Features == nil {
continue
}
for _, feature := range facet.Features {
if feature.RichtextFacet_Mention != nil && feature.RichtextFacet_Mention.Did == userDID {
return true
}
}
}
return false
}
// BlueskyPostInfo represents a Bluesky post for export
type BlueskyPostInfo struct {
Rkey string
Text string
CreatedAt string
}
// ListBlueskyPostsForUser returns all Bluesky posts that mention a user's DID
func (p *HoldPDS) ListBlueskyPostsForUser(ctx context.Context, userDID string) ([]BlueskyPostInfo, error) {
if p.recordsIndex == nil {
return nil, fmt.Errorf("records index not available")
}
var posts []BlueskyPostInfo
cursor := ""
batchSize := 100
for {
// Get all Bluesky posts
records, nextCursor, err := p.recordsIndex.ListRecords(atproto.BskyPostCollection, batchSize, cursor, false)
if err != nil {
return posts, fmt.Errorf("failed to list bluesky posts: %w", err)
}
for _, rec := range records {
// Get the record bytes to check the facets
recordPath := rec.Collection + "/" + rec.Rkey
_, recBytes, err := p.GetRecordBytes(ctx, recordPath)
if err != nil {
slog.Warn("Failed to get post record bytes for export",
"rkey", rec.Rkey,
"error", err)
continue
}
if recBytes == nil {
continue
}
// Parse as FeedPost to check facets
var post bsky.FeedPost
if err := post.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
slog.Warn("Failed to unmarshal post record for export",
"rkey", rec.Rkey,
"error", err)
continue
}
// Check if any facet mentions this user's DID
if !postMentionsUser(&post, userDID) {
continue
}
posts = append(posts, BlueskyPostInfo{
Rkey: rec.Rkey,
Text: post.Text,
CreatedAt: post.CreatedAt,
})
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
return posts, nil
}