mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-06-09 16:52:35 +00:00
update privacy policy, add exporting/deleting bluesky posts as part of userdata
This commit is contained in:
@@ -43,6 +43,7 @@ type HoldDeleteResult struct {
|
||||
CrewDeleted bool `json:"crew_deleted,omitempty"`
|
||||
LayersDeleted int `json:"layers_deleted,omitempty"`
|
||||
StatsDeleted int `json:"stats_deleted,omitempty"`
|
||||
PostsDeleted int `json:"posts_deleted,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteAccountHandler handles GDPR account deletion requests
|
||||
@@ -267,6 +268,7 @@ func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *d
|
||||
CrewDeleted bool `json:"crew_deleted"`
|
||||
LayersDeleted int `json:"layers_deleted"`
|
||||
StatsDeleted int `json:"stats_deleted"`
|
||||
PostsDeleted int `json:"posts_deleted"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&holdResponse); err != nil {
|
||||
result.Error = fmt.Sprintf("Failed to parse response: %v", err)
|
||||
@@ -278,6 +280,7 @@ func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *d
|
||||
result.CrewDeleted = holdResponse.CrewDeleted
|
||||
result.LayersDeleted = holdResponse.LayersDeleted
|
||||
result.StatsDeleted = holdResponse.StatsDeleted
|
||||
result.PostsDeleted = holdResponse.PostsDeleted
|
||||
|
||||
slog.Debug("Successfully deleted data from hold",
|
||||
"component", "delete",
|
||||
@@ -285,7 +288,8 @@ func (h *DeleteAccountHandler) deleteFromSingleHold(ctx context.Context, user *d
|
||||
"user_did", user.DID,
|
||||
"crew_deleted", holdResponse.CrewDeleted,
|
||||
"layers_deleted", holdResponse.LayersDeleted,
|
||||
"stats_deleted", holdResponse.StatsDeleted)
|
||||
"stats_deleted", holdResponse.StatsDeleted,
|
||||
"posts_deleted", holdResponse.PostsDeleted)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<h3>Data Stored on Our Infrastructure</h3>
|
||||
|
||||
<p><strong>Layer Records:</strong> We maintain records on our own PDS that reference container image layers you publish. These records are public and link your AT Protocol identity (DID) to content-addressed SHA identifiers.</p>
|
||||
<p><strong>Layer Records:</strong> Our hold services (e.g., <code>hold01.atcr.io</code>) maintain records in their embedded PDS that reference container image layers you publish. These records are public and link your AT Protocol identity (DID) to content-addressed SHA identifiers.</p>
|
||||
|
||||
<p><strong>OCI Blobs:</strong> Container image layers are stored in our object storage (S3). These blobs are content-addressed and deduplicated—meaning identical layers uploaded by different users are stored only once.</p>
|
||||
|
||||
@@ -43,6 +43,33 @@
|
||||
<p><strong>Server Logs:</strong> Our logs may include your handle, DID, IP address, timestamps, and actions performed. Logs are currently ephemeral but may be retained in the future for security and debugging purposes.</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<h2>Our Services and Their Data</h2>
|
||||
|
||||
<p>AT Container Registry consists of multiple services, each with distinct data responsibilities:</p>
|
||||
|
||||
<h3>AppView (atcr.io)</h3>
|
||||
<p>The registry frontend you interact with directly. Stores:</p>
|
||||
<ul>
|
||||
<li>OAuth sessions and tokens for authentication</li>
|
||||
<li>Device tokens for the Docker credential helper</li>
|
||||
<li>Web UI sessions</li>
|
||||
<li>Cached metadata from your PDS (indexes for search and display)</li>
|
||||
</ul>
|
||||
|
||||
<h3>ATCR-Hosted Hold Services</h3>
|
||||
<p>Storage backends we operate (e.g., <code>hold01.atcr.io</code>). Each hold has an embedded PDS and stores:</p>
|
||||
<ul>
|
||||
<li>OCI blobs (container image layers) in object storage</li>
|
||||
<li>Layer records in the hold's embedded PDS linking your DID to blob references</li>
|
||||
<li>Crew membership records for access control</li>
|
||||
</ul>
|
||||
<p>Hold services on <code>*.atcr.io</code> domains are operated by us and covered by this policy.</p>
|
||||
|
||||
<h3>User-Deployed Hold Services (BYOS)</h3>
|
||||
<p>You may use "Bring Your Own Storage" by deploying your own hold service. Data on user-deployed holds is governed by that operator's privacy policy, not ours. We can request deletion on your behalf but cannot guarantee it for services we do not control.</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<h2>Data Sharing and Deduplication</h2>
|
||||
|
||||
@@ -75,17 +102,28 @@
|
||||
<h3>Right to Erasure ("Right to be Forgotten")</h3>
|
||||
<p>You may request deletion of your data via the account settings page. Due to our technical architecture, deletion works as follows:</p>
|
||||
|
||||
<p><strong>Immediately deleted:</strong></p>
|
||||
<p><strong>Immediately deleted from AppView:</strong></p>
|
||||
<ul>
|
||||
<li>Layer records on our PDS that reference your DID</li>
|
||||
<li>OAuth tokens, web UI sessions, and device tokens</li>
|
||||
<li>Cached PDS data</li>
|
||||
<li>Cached PDS data (manifest and tag indexes)</li>
|
||||
<li>Server logs containing your identifiers (deleted or anonymized, if retained)</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Deleted within 30 days:</strong></p>
|
||||
<p><strong>Immediately deleted from ATCR-hosted holds:</strong></p>
|
||||
<ul>
|
||||
<li>OCI blobs in our object storage that are no longer referenced by any user after your records are removed (via our orphan blob pruning process)</li>
|
||||
<li>Layer records in the hold's embedded PDS that reference your DID</li>
|
||||
<li>Crew membership records</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Deleted within 30 days from ATCR-hosted holds:</strong></p>
|
||||
<ul>
|
||||
<li>OCI blobs in object storage that are no longer referenced by any user (via garbage collection)</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>User-deployed holds:</strong></p>
|
||||
<ul>
|
||||
<li>We attempt to delete your data via API, but success depends on hold availability</li>
|
||||
<li>Data on holds we do not operate is governed by that operator's policies</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Cannot be deleted by us:</strong></p>
|
||||
@@ -174,37 +212,45 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data Type</th>
|
||||
<th>Service</th>
|
||||
<th>Retention Period</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>OAuth tokens</td>
|
||||
<td>AppView</td>
|
||||
<td>Until revoked or logout</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Web UI session tokens</td>
|
||||
<td>AppView</td>
|
||||
<td>Until logout or expiration</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Device tokens (credential helper)</td>
|
||||
<td>AppView</td>
|
||||
<td>Until revoked by user</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cached PDS data</td>
|
||||
<td>AppView</td>
|
||||
<td>Refreshed periodically; deleted on account deletion</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Server logs</td>
|
||||
<td>AppView</td>
|
||||
<td>Currently ephemeral; this policy will be updated if log retention is implemented</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Layer records (our PDS)</td>
|
||||
<td>Layer records</td>
|
||||
<td>Hold PDS</td>
|
||||
<td>Until you request deletion</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OCI blobs</td>
|
||||
<td>Until no longer referenced (pruned monthly)</td>
|
||||
<td>Hold Storage</td>
|
||||
<td>Until no longer referenced (pruned within 30 days)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -223,6 +269,32 @@
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<h2>Bring Your Own Storage (BYOS)</h2>
|
||||
|
||||
<p>AT Container Registry supports "Bring Your Own Storage" where users can deploy their own hold services to store container image blobs. This section explains how BYOS affects your privacy rights.</p>
|
||||
|
||||
<h3>ATCR-Hosted Holds</h3>
|
||||
<p>Hold services on <code>*.atcr.io</code> domains (e.g., <code>hold01.atcr.io</code>) are operated by us and fully covered by this privacy policy. We can fulfill all data access, export, and deletion requests for these services.</p>
|
||||
|
||||
<h3>User-Deployed Holds</h3>
|
||||
<p>If you use a hold service not operated by us:</p>
|
||||
<ul>
|
||||
<li>That hold's data practices are governed by its operator's privacy policy, not ours</li>
|
||||
<li>When you request account deletion, we attempt to delete your data from all holds via API</li>
|
||||
<li>We cannot guarantee deletion for holds that are offline or refuse the request</li>
|
||||
<li>You should contact that hold's operator directly for data requests we cannot fulfill</li>
|
||||
</ul>
|
||||
|
||||
<h3>If You Operate a Hold</h3>
|
||||
<p>If you deploy your own hold service and allow other users to store data on it, you become a data controller for that data under GDPR/CCPA. You are responsible for:</p>
|
||||
<ul>
|
||||
<li>Responding to deletion requests from users of your hold</li>
|
||||
<li>Implementing appropriate data retention policies</li>
|
||||
<li>Publishing your own privacy policy if required by law</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="legal-section">
|
||||
<h2>How to Exercise Your Rights</h2>
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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
|
||||
@@ -13,6 +15,7 @@ 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.
|
||||
@@ -20,6 +23,7 @@ type UserDeleteResult struct {
|
||||
// - 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.
|
||||
@@ -60,12 +64,23 @@ func (p *HoldPDS) DeleteUserData(ctx context.Context, userDID string) (*UserDele
|
||||
}
|
||||
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)
|
||||
"stats_deleted", result.StatsDeleted,
|
||||
"posts_deleted", result.PostsDeleted)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -190,3 +205,171 @@ func (p *HoldPDS) deleteStatsRecords(ctx context.Context, userDID string) (int,
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
337
pkg/hold/pds/delete_test.go
Normal file
337
pkg/hold/pds/delete_test.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
bsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
)
|
||||
|
||||
func TestPostMentionsUser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
post *bsky.FeedPost
|
||||
userDID string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "post mentions user",
|
||||
post: &bsky.FeedPost{
|
||||
Text: "@alice.bsky.social pushed myapp:latest",
|
||||
Facets: []*bsky.RichtextFacet{{
|
||||
Index: &bsky.RichtextFacet_ByteSlice{
|
||||
ByteStart: 0,
|
||||
ByteEnd: 20,
|
||||
},
|
||||
Features: []*bsky.RichtextFacet_Features_Elem{{
|
||||
RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
|
||||
Did: "did:plc:alice123",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
userDID: "did:plc:alice123",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "post mentions different user",
|
||||
post: &bsky.FeedPost{
|
||||
Text: "@bob.bsky.social pushed myapp:latest",
|
||||
Facets: []*bsky.RichtextFacet{{
|
||||
Index: &bsky.RichtextFacet_ByteSlice{
|
||||
ByteStart: 0,
|
||||
ByteEnd: 18,
|
||||
},
|
||||
Features: []*bsky.RichtextFacet_Features_Elem{{
|
||||
RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
|
||||
Did: "did:plc:bob456",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
userDID: "did:plc:alice123",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "post with no facets",
|
||||
post: &bsky.FeedPost{
|
||||
Text: "Just a regular post",
|
||||
Facets: nil,
|
||||
},
|
||||
userDID: "did:plc:alice123",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "post with empty facets",
|
||||
post: &bsky.FeedPost{
|
||||
Text: "Just a regular post",
|
||||
Facets: []*bsky.RichtextFacet{},
|
||||
},
|
||||
userDID: "did:plc:alice123",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "post with link facet only (no mention)",
|
||||
post: &bsky.FeedPost{
|
||||
Text: "Check out https://example.com",
|
||||
Facets: []*bsky.RichtextFacet{{
|
||||
Index: &bsky.RichtextFacet_ByteSlice{
|
||||
ByteStart: 10,
|
||||
ByteEnd: 30,
|
||||
},
|
||||
Features: []*bsky.RichtextFacet_Features_Elem{{
|
||||
RichtextFacet_Link: &bsky.RichtextFacet_Link{
|
||||
Uri: "https://example.com",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
userDID: "did:plc:alice123",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "post with multiple mentions - match second",
|
||||
post: &bsky.FeedPost{
|
||||
Text: "@bob.bsky.social and @alice.bsky.social pushed",
|
||||
Facets: []*bsky.RichtextFacet{
|
||||
{
|
||||
Index: &bsky.RichtextFacet_ByteSlice{
|
||||
ByteStart: 0,
|
||||
ByteEnd: 18,
|
||||
},
|
||||
Features: []*bsky.RichtextFacet_Features_Elem{{
|
||||
RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
|
||||
Did: "did:plc:bob456",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Index: &bsky.RichtextFacet_ByteSlice{
|
||||
ByteStart: 23,
|
||||
ByteEnd: 43,
|
||||
},
|
||||
Features: []*bsky.RichtextFacet_Features_Elem{{
|
||||
RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
|
||||
Did: "did:plc:alice123",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
userDID: "did:plc:alice123",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "facet with nil features",
|
||||
post: &bsky.FeedPost{
|
||||
Text: "Some post",
|
||||
Facets: []*bsky.RichtextFacet{{
|
||||
Index: &bsky.RichtextFacet_ByteSlice{
|
||||
ByteStart: 0,
|
||||
ByteEnd: 4,
|
||||
},
|
||||
Features: nil,
|
||||
}},
|
||||
},
|
||||
userDID: "did:plc:alice123",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := postMentionsUser(tt.post, tt.userDID)
|
||||
if result != tt.expected {
|
||||
t.Errorf("postMentionsUser() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBlueskyPosts_NoPosts(t *testing.T) {
|
||||
// Create a test PDS with records index
|
||||
pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
|
||||
defer cleanup()
|
||||
|
||||
ctx := sharedCtx
|
||||
|
||||
// Delete posts for a user that has no posts
|
||||
deleted, err := pds.deleteBlueskyPosts(ctx, "did:plc:nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("deleteBlueskyPosts() error = %v", err)
|
||||
}
|
||||
|
||||
if deleted != 0 {
|
||||
t.Errorf("deleteBlueskyPosts() deleted = %d, want 0", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBlueskyPostsForUser_NoPosts(t *testing.T) {
|
||||
// Create a test PDS with records index
|
||||
pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
|
||||
defer cleanup()
|
||||
|
||||
ctx := sharedCtx
|
||||
|
||||
// List posts for a user that has no posts
|
||||
posts, err := pds.ListBlueskyPostsForUser(ctx, "did:plc:nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("ListBlueskyPostsForUser() error = %v", err)
|
||||
}
|
||||
|
||||
if len(posts) != 0 {
|
||||
t.Errorf("ListBlueskyPostsForUser() returned %d posts, want 0", len(posts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAndListBlueskyPosts_WithPosts(t *testing.T) {
|
||||
// Create a test PDS with records index
|
||||
pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
|
||||
defer cleanup()
|
||||
|
||||
ctx := sharedCtx
|
||||
|
||||
// Create a test post that mentions alice
|
||||
aliceDID := "did:plc:alice123"
|
||||
post := &bsky.FeedPost{
|
||||
LexiconTypeID: atproto.BskyPostCollection,
|
||||
Text: "@alice.bsky.social pushed myapp:latest",
|
||||
Facets: []*bsky.RichtextFacet{{
|
||||
Index: &bsky.RichtextFacet_ByteSlice{
|
||||
ByteStart: 0,
|
||||
ByteEnd: 20,
|
||||
},
|
||||
Features: []*bsky.RichtextFacet_Features_Elem{{
|
||||
RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
|
||||
Did: aliceDID,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
// Create the post in the PDS
|
||||
rkey, _, err := pds.repomgr.CreateRecord(ctx, pds.uid, atproto.BskyPostCollection, post)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test post: %v", err)
|
||||
}
|
||||
|
||||
// Index the record
|
||||
if pds.recordsIndex != nil {
|
||||
err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to index test post: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// List posts for alice - should find 1
|
||||
posts, err := pds.ListBlueskyPostsForUser(ctx, aliceDID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListBlueskyPostsForUser() error = %v", err)
|
||||
}
|
||||
|
||||
if len(posts) != 1 {
|
||||
t.Errorf("ListBlueskyPostsForUser() returned %d posts, want 1", len(posts))
|
||||
}
|
||||
|
||||
if len(posts) > 0 {
|
||||
if posts[0].Text != "@alice.bsky.social pushed myapp:latest" {
|
||||
t.Errorf("Post text = %q, want %q", posts[0].Text, "@alice.bsky.social pushed myapp:latest")
|
||||
}
|
||||
if posts[0].CreatedAt != "2025-01-01T00:00:00Z" {
|
||||
t.Errorf("Post createdAt = %q, want %q", posts[0].CreatedAt, "2025-01-01T00:00:00Z")
|
||||
}
|
||||
}
|
||||
|
||||
// List posts for bob - should find 0
|
||||
bobPosts, err := pds.ListBlueskyPostsForUser(ctx, "did:plc:bob456")
|
||||
if err != nil {
|
||||
t.Fatalf("ListBlueskyPostsForUser(bob) error = %v", err)
|
||||
}
|
||||
|
||||
if len(bobPosts) != 0 {
|
||||
t.Errorf("ListBlueskyPostsForUser(bob) returned %d posts, want 0", len(bobPosts))
|
||||
}
|
||||
|
||||
// Delete posts for alice
|
||||
deleted, err := pds.deleteBlueskyPosts(ctx, aliceDID)
|
||||
if err != nil {
|
||||
t.Fatalf("deleteBlueskyPosts() error = %v", err)
|
||||
}
|
||||
|
||||
if deleted != 1 {
|
||||
t.Errorf("deleteBlueskyPosts() deleted = %d, want 1", deleted)
|
||||
}
|
||||
|
||||
// List posts for alice again - should find 0 now
|
||||
postsAfterDelete, err := pds.ListBlueskyPostsForUser(ctx, aliceDID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListBlueskyPostsForUser() after delete error = %v", err)
|
||||
}
|
||||
|
||||
if len(postsAfterDelete) != 0 {
|
||||
t.Errorf("ListBlueskyPostsForUser() after delete returned %d posts, want 0", len(postsAfterDelete))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserData_IncludesPosts(t *testing.T) {
|
||||
// Create a test PDS with records index
|
||||
pds, cleanup := setupTestPDSWithIndex(t, "did:plc:testowner")
|
||||
defer cleanup()
|
||||
|
||||
ctx := sharedCtx
|
||||
|
||||
// Create a test post that mentions alice
|
||||
aliceDID := "did:plc:alice123"
|
||||
post := &bsky.FeedPost{
|
||||
LexiconTypeID: atproto.BskyPostCollection,
|
||||
Text: "@alice.bsky.social pushed myapp:latest",
|
||||
Facets: []*bsky.RichtextFacet{{
|
||||
Index: &bsky.RichtextFacet_ByteSlice{
|
||||
ByteStart: 0,
|
||||
ByteEnd: 20,
|
||||
},
|
||||
Features: []*bsky.RichtextFacet_Features_Elem{{
|
||||
RichtextFacet_Mention: &bsky.RichtextFacet_Mention{
|
||||
Did: aliceDID,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
// Create the post in the PDS
|
||||
rkey, _, err := pds.repomgr.CreateRecord(ctx, pds.uid, atproto.BskyPostCollection, post)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test post: %v", err)
|
||||
}
|
||||
|
||||
// Index the record
|
||||
if pds.recordsIndex != nil {
|
||||
err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to index test post: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call DeleteUserData
|
||||
result, err := pds.DeleteUserData(ctx, aliceDID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUserData() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify posts were deleted
|
||||
if result.PostsDeleted != 1 {
|
||||
t.Errorf("DeleteUserData() PostsDeleted = %d, want 1", result.PostsDeleted)
|
||||
}
|
||||
|
||||
// Verify post is actually gone
|
||||
posts, err := pds.ListBlueskyPostsForUser(ctx, aliceDID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListBlueskyPostsForUser() error = %v", err)
|
||||
}
|
||||
|
||||
if len(posts) != 0 {
|
||||
t.Errorf("Posts still exist after DeleteUserData, count = %d", len(posts))
|
||||
}
|
||||
}
|
||||
@@ -1499,13 +1499,14 @@ func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HoldUserDataExport represents the GDPR data export from a hold service
|
||||
type HoldUserDataExport struct {
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
HoldDID string `json:"hold_did"`
|
||||
UserDID string `json:"user_did"`
|
||||
IsCaptain bool `json:"is_captain"`
|
||||
CrewRecord *CrewExport `json:"crew_record,omitempty"`
|
||||
LayerRecords []LayerExport `json:"layer_records"`
|
||||
StatsRecords []StatsExport `json:"stats_records"`
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
HoldDID string `json:"hold_did"`
|
||||
UserDID string `json:"user_did"`
|
||||
IsCaptain bool `json:"is_captain"`
|
||||
CrewRecord *CrewExport `json:"crew_record,omitempty"`
|
||||
LayerRecords []LayerExport `json:"layer_records"`
|
||||
StatsRecords []StatsExport `json:"stats_records"`
|
||||
BlueskyPosts []BlueskyPostExport `json:"bluesky_posts"`
|
||||
}
|
||||
|
||||
// CrewExport represents a sanitized crew record for export
|
||||
@@ -1535,6 +1536,13 @@ type StatsExport struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BlueskyPostExport represents a Bluesky post that mentions the user
|
||||
type BlueskyPostExport struct {
|
||||
URI string `json:"uri"` // at://did/app.bsky.feed.post/rkey
|
||||
Text string `json:"text"` // Post content
|
||||
CreatedAt string `json:"created_at"` // When the post was created
|
||||
}
|
||||
|
||||
// HandleExportUserData handles GDPR data export requests for a specific user.
|
||||
// This endpoint returns all records stored on this hold's PDS that reference
|
||||
// the authenticated user's DID.
|
||||
@@ -1543,6 +1551,7 @@ type StatsExport struct {
|
||||
// - io.atcr.hold.layer records where userDid matches
|
||||
// - io.atcr.hold.crew record for the DID (if exists)
|
||||
// - io.atcr.hold.stats records where ownerDid matches
|
||||
// - app.bsky.feed.post records that mention the user
|
||||
// - Whether the user is the hold captain
|
||||
//
|
||||
// Authentication: Requires valid service token from user's PDS
|
||||
@@ -1564,6 +1573,7 @@ func (h *XRPCHandler) HandleExportUserData(w http.ResponseWriter, r *http.Reques
|
||||
UserDID: user.DID,
|
||||
LayerRecords: []LayerExport{},
|
||||
StatsRecords: []StatsExport{},
|
||||
BlueskyPosts: []BlueskyPostExport{},
|
||||
}
|
||||
|
||||
// Check if user is captain
|
||||
@@ -1622,13 +1632,31 @@ func (h *XRPCHandler) HandleExportUserData(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}
|
||||
|
||||
// Get Bluesky posts that mention this user (GDPR compliance)
|
||||
blueskyPosts, err := h.pds.ListBlueskyPostsForUser(r.Context(), user.DID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get bluesky posts for export",
|
||||
"user_did", user.DID,
|
||||
"error", err)
|
||||
// Continue with empty list - don't fail entire export
|
||||
} else {
|
||||
for _, post := range blueskyPosts {
|
||||
export.BlueskyPosts = append(export.BlueskyPosts, BlueskyPostExport{
|
||||
URI: fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.BskyPostCollection, post.Rkey),
|
||||
Text: post.Text,
|
||||
CreatedAt: post.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("GDPR data export completed",
|
||||
"user_did", user.DID,
|
||||
"hold_did", h.pds.DID(),
|
||||
"is_captain", export.IsCaptain,
|
||||
"has_crew_record", export.CrewRecord != nil,
|
||||
"layer_count", len(export.LayerRecords),
|
||||
"stats_count", len(export.StatsRecords))
|
||||
"stats_count", len(export.StatsRecords),
|
||||
"post_count", len(export.BlueskyPosts))
|
||||
|
||||
render.JSON(w, r, export)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user