mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
376 lines
9.4 KiB
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
|
|
}
|