mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 12:14:17 +00:00
implement hold discovery dropdown in settings. implement a data privacy export feature
This commit is contained in:
@@ -7,8 +7,10 @@ package hold
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -54,6 +56,9 @@ type RegistrationConfig struct {
|
||||
// If true, creates posts when users push images
|
||||
// Synced to captain record's enableBlueskyPosts field on startup
|
||||
EnableBlueskyPosts bool `yaml:"enable_bluesky_posts"`
|
||||
|
||||
// Region is the deployment region, auto-detected from cloud metadata or S3 config
|
||||
Region string `yaml:"region"`
|
||||
}
|
||||
|
||||
// StorageConfig wraps distribution's storage configuration
|
||||
@@ -148,6 +153,18 @@ func LoadConfigFromEnv() (*Config, error) {
|
||||
// Admin panel configuration
|
||||
cfg.Admin.Enabled = os.Getenv("HOLD_ADMIN_ENABLED") == "true"
|
||||
|
||||
// Detect region from cloud metadata or S3 config
|
||||
if meta, err := DetectCloudMetadata(context.Background()); err == nil && meta != nil {
|
||||
cfg.Registration.Region = meta.Region
|
||||
slog.Info("Detected cloud metadata", "region", meta.Region)
|
||||
} else {
|
||||
// Fall back to S3 region
|
||||
if storageType == "s3" {
|
||||
cfg.Registration.Region = getEnvOrDefault("AWS_REGION", "us-east-1")
|
||||
slog.Info("Using S3 region", "region", cfg.Registration.Region)
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -200,6 +217,7 @@ func getEnvOrDefault(key, defaultValue string) string {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
|
||||
// RequestCrawl sends a crawl request to the ATProto relay for the given hostname.
|
||||
// This makes the hold's PDS discoverable by the relay network.
|
||||
func RequestCrawl(relayEndpoint, publicURL string) error {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package hold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CloudMetadata contains region info from cloud metadata service
|
||||
type CloudMetadata struct {
|
||||
Region string
|
||||
}
|
||||
|
||||
// DetectCloudMetadata queries the instance metadata service (169.254.169.254)
|
||||
// Currently supports UpCloud. Others can be added via PR.
|
||||
func DetectCloudMetadata(ctx context.Context) (*CloudMetadata, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Try UpCloud metadata format
|
||||
if meta, err := detectUpCloud(ctx); err == nil {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// Add other providers here (AWS, GCP, Azure, DigitalOcean, etc.)
|
||||
// Contributors welcome!
|
||||
|
||||
return nil, nil // No metadata available
|
||||
}
|
||||
|
||||
// detectUpCloud queries UpCloud's metadata service
|
||||
func detectUpCloud(ctx context.Context) (*CloudMetadata, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://169.254.169.254/metadata/v1.json", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("metadata returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
CloudName string `json:"cloud_name"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if data.CloudName != "upcloud" {
|
||||
return nil, fmt.Errorf("not upcloud: %s", data.CloudName)
|
||||
}
|
||||
|
||||
return &CloudMetadata{
|
||||
Region: data.Region,
|
||||
}, nil
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func setupTestOCIHandler(t *testing.T) (*XRPCHandler, context.Context) {
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "")
|
||||
err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
|
||||
@@ -17,7 +17,7 @@ const (
|
||||
|
||||
// CreateCaptainRecord creates the captain record for the hold (first-time only).
|
||||
// This will FAIL if the captain record already exists. Use UpdateCaptainRecord to modify.
|
||||
func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool) (cid.Cid, error) {
|
||||
func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool, region string) (cid.Cid, error) {
|
||||
captainRecord := &atproto.CaptainRecord{
|
||||
Type: atproto.CaptainCollection,
|
||||
Owner: ownerDID,
|
||||
@@ -25,6 +25,7 @@ func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, publ
|
||||
AllowAllCrew: allowAllCrew,
|
||||
EnableBlueskyPosts: enableBlueskyPosts,
|
||||
DeployedAt: time.Now().Format(time.RFC3339),
|
||||
Region: region,
|
||||
}
|
||||
|
||||
// Use repomgr.PutRecord - creates with explicit rkey, fails if already exists
|
||||
|
||||
@@ -55,7 +55,7 @@ func setupTestPDSWithBootstrap(t *testing.T, ownerDID string, public, allowAllCr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "")
|
||||
err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "")
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
@@ -114,7 +114,7 @@ func TestCreateCaptainRecord(t *testing.T) {
|
||||
defer pds.Close()
|
||||
|
||||
// Create captain record
|
||||
recordCID, err := pds.CreateCaptainRecord(ctx, tt.ownerDID, tt.public, tt.allowAllCrew, tt.enableBlueskyPosts)
|
||||
recordCID, err := pds.CreateCaptainRecord(ctx, tt.ownerDID, tt.public, tt.allowAllCrew, tt.enableBlueskyPosts, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCaptainRecord failed: %v", err)
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func TestGetCaptainRecord(t *testing.T) {
|
||||
ownerDID := "did:plc:alice123"
|
||||
|
||||
// Create captain record
|
||||
createdCID, err := pds.CreateCaptainRecord(ctx, ownerDID, true, false, false)
|
||||
createdCID, err := pds.CreateCaptainRecord(ctx, ownerDID, true, false, false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCaptainRecord failed: %v", err)
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestUpdateCaptainRecord(t *testing.T) {
|
||||
ownerDID := "did:plc:alice123"
|
||||
|
||||
// Create initial captain record (public=false, allowAllCrew=false, enableBlueskyPosts=false)
|
||||
_, err := pds.CreateCaptainRecord(ctx, ownerDID, false, false, false)
|
||||
_, err := pds.CreateCaptainRecord(ctx, ownerDID, false, false, false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCaptainRecord failed: %v", err)
|
||||
}
|
||||
@@ -343,7 +343,6 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
|
||||
AllowAllCrew: true,
|
||||
DeployedAt: "2025-10-16T12:00:00Z",
|
||||
Region: "us-west-2",
|
||||
Provider: "fly.io",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -355,7 +354,6 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
|
||||
AllowAllCrew: true,
|
||||
DeployedAt: "2025-10-16T12:00:00Z",
|
||||
Region: "",
|
||||
Provider: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -400,9 +398,6 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
|
||||
if decoded.Region != tt.record.Region {
|
||||
t.Errorf("Region mismatch: expected %s, got %s", tt.record.Region, decoded.Region)
|
||||
}
|
||||
if decoded.Provider != tt.record.Provider {
|
||||
t.Errorf("Provider mismatch: expected %s, got %s", tt.record.Provider, decoded.Provider)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,3 +212,85 @@ func (p *HoldPDS) getCrewTier(ctx context.Context, userDID string) string {
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListLayerRecordsForUser returns all layer records uploaded by a specific user
|
||||
// Used for GDPR data export to return all layers a user has pushed to this hold
|
||||
func (p *HoldPDS) ListLayerRecordsForUser(ctx context.Context, userDID string) ([]*atproto.LayerRecord, error) {
|
||||
if p.recordsIndex == nil {
|
||||
return nil, fmt.Errorf("records index not available")
|
||||
}
|
||||
|
||||
// Get session for reading record data
|
||||
session, err := p.carstore.ReadOnlySession(p.uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repo head: %w", err)
|
||||
}
|
||||
|
||||
if !head.Defined() {
|
||||
// Empty repo - return empty list
|
||||
return []*atproto.LayerRecord{}, nil
|
||||
}
|
||||
|
||||
repoHandle, err := repo.OpenRepo(ctx, session, head)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open repo: %w", err)
|
||||
}
|
||||
|
||||
var records []*atproto.LayerRecord
|
||||
|
||||
// Iterate all layer records via the index
|
||||
cursor := ""
|
||||
batchSize := 1000 // Process in batches
|
||||
|
||||
for {
|
||||
indexRecords, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list layer records: %w", err)
|
||||
}
|
||||
|
||||
for _, rec := range indexRecords {
|
||||
// Construct record path and get the record data
|
||||
recordPath := rec.Collection + "/" + rec.Rkey
|
||||
|
||||
_, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath)
|
||||
if err != nil {
|
||||
// Skip records we can't read
|
||||
continue
|
||||
}
|
||||
|
||||
// Decode the layer record
|
||||
recordValue, err := lexutil.CborDecodeValue(*recBytes)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
layerRecord, ok := recordValue.(*atproto.LayerRecord)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by userDID
|
||||
if layerRecord.UserDID != userDID {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, layerRecord)
|
||||
}
|
||||
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if records == nil {
|
||||
records = []*atproto.LayerRecord{}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ func setupTestPDSWithIndex(t *testing.T, ownerDID string) (*HoldPDS, func()) {
|
||||
}
|
||||
|
||||
// Bootstrap with owner
|
||||
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
|
||||
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
|
||||
t.Fatalf("Failed to bootstrap PDS: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ func (p *HoldPDS) UID() models.Uid {
|
||||
}
|
||||
|
||||
// Bootstrap initializes the hold with the captain record, owner as first crew member, and profile
|
||||
func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL string) error {
|
||||
func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error {
|
||||
if ownerDID == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
|
||||
}
|
||||
|
||||
// Create captain record (hold ownership and settings)
|
||||
_, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts)
|
||||
_, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts, region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create captain record: %w", err)
|
||||
}
|
||||
@@ -193,7 +193,8 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
|
||||
slog.Info("Created captain record",
|
||||
"public", public,
|
||||
"allowAllCrew", allowAllCrew,
|
||||
"enableBlueskyPosts", p.enableBlueskyPosts)
|
||||
"enableBlueskyPosts", p.enableBlueskyPosts,
|
||||
"region", region)
|
||||
|
||||
// Add hold owner as first crew member with admin role
|
||||
_, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
|
||||
|
||||
+13
-13
@@ -69,7 +69,7 @@ func TestNewHoldPDS_ExistingRepo(t *testing.T) {
|
||||
|
||||
// Bootstrap with a captain record
|
||||
ownerDID := "did:plc:owner123"
|
||||
if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
|
||||
if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestBootstrap_NewRepo(t *testing.T) {
|
||||
publicAccess := true
|
||||
allowAllCrew := false
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "")
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func TestBootstrap_Idempotent(t *testing.T) {
|
||||
ownerDID := "did:plc:alice123"
|
||||
|
||||
// First bootstrap
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("First bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -223,7 +223,7 @@ func TestBootstrap_Idempotent(t *testing.T) {
|
||||
crewCount1 := len(crew1)
|
||||
|
||||
// Second bootstrap (should be idempotent - skip creation)
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Second bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func TestBootstrap_EmptyOwner(t *testing.T) {
|
||||
defer pds.Close()
|
||||
|
||||
// Bootstrap with empty owner DID (should be no-op)
|
||||
err = pds.Bootstrap(ctx, nil, "", true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, "", true, false, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap with empty owner should not error: %v", err)
|
||||
}
|
||||
@@ -302,7 +302,7 @@ func TestLexiconTypeRegistration(t *testing.T) {
|
||||
|
||||
// Bootstrap to create captain record
|
||||
ownerDID := "did:plc:alice123"
|
||||
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
|
||||
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ func TestBootstrap_DidWebOwner(t *testing.T) {
|
||||
publicAccess := true
|
||||
allowAllCrew := false
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "")
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed with did:web owner: %v", err)
|
||||
}
|
||||
@@ -414,7 +414,7 @@ func TestBootstrap_MixedDIDs(t *testing.T) {
|
||||
|
||||
// Bootstrap with did:plc owner
|
||||
plcOwner := "did:plc:alice123"
|
||||
err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -509,7 +509,7 @@ func TestBootstrap_CrewWithoutCaptain(t *testing.T) {
|
||||
}
|
||||
|
||||
// Bootstrap should create captain record
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -559,7 +559,7 @@ func TestBootstrap_CaptainWithoutCrew(t *testing.T) {
|
||||
|
||||
// Create captain record WITHOUT crew (unusual state)
|
||||
ownerDID := "did:plc:alice123"
|
||||
_, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false, false)
|
||||
_, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false, false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCaptainRecord failed: %v", err)
|
||||
}
|
||||
@@ -584,7 +584,7 @@ func TestBootstrap_CaptainWithoutCrew(t *testing.T) {
|
||||
|
||||
// Bootstrap should be idempotent but notice missing crew
|
||||
// Currently Bootstrap skips if captain exists, so crew won't be added
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -856,7 +856,7 @@ func TestHoldPDS_BackfillRecordsIndex(t *testing.T) {
|
||||
|
||||
// Bootstrap to create some records in MST (captain + crew)
|
||||
ownerDID := "did:plc:testowner"
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
@@ -921,7 +921,7 @@ func TestHoldPDS_BackfillRecordsIndex_SkipsWhenSynced(t *testing.T) {
|
||||
defer pds.Close()
|
||||
|
||||
// Bootstrap to create records
|
||||
err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Bootstrap failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -216,3 +216,26 @@ func (p *HoldPDS) ListStats(ctx context.Context) ([]*atproto.StatsRecord, error)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ListStatsRecordsForUser returns all stats records where the user is the repository owner
|
||||
// Used for GDPR data export to return all stats for repositories owned by the user
|
||||
func (p *HoldPDS) ListStatsRecordsForUser(ctx context.Context, userDID string) ([]*atproto.StatsRecord, error) {
|
||||
// Get all stats records and filter by ownerDID
|
||||
allStats, err := p.ListStats(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var userStats []*atproto.StatsRecord
|
||||
for _, stat := range allStats {
|
||||
if stat.OwnerDID == userDID {
|
||||
userStats = append(userStats, stat)
|
||||
}
|
||||
}
|
||||
|
||||
if userStats == nil {
|
||||
userStats = []*atproto.StatsRecord{}
|
||||
}
|
||||
|
||||
return userStats, nil
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
// Bootstrap once
|
||||
ownerDID := "did:plc:testowner123"
|
||||
err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "")
|
||||
err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "", "")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to bootstrap shared PDS: %v", err))
|
||||
}
|
||||
|
||||
@@ -195,6 +195,8 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.requireAuth)
|
||||
r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew)
|
||||
// GDPR data export endpoint (TODO: implement)
|
||||
r.Get("/xrpc/io.atcr.hold.exportUserData", h.HandleExportUserData)
|
||||
})
|
||||
|
||||
// Public quota endpoint (no auth - quota is per-user, just needs userDid param)
|
||||
@@ -1492,3 +1494,139 @@ func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.JSON(w, r, stats)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// CrewExport represents a sanitized crew record for export
|
||||
type CrewExport struct {
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
AddedAt string `json:"added_at"`
|
||||
}
|
||||
|
||||
// LayerExport represents a layer record for export
|
||||
type LayerExport struct {
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
MediaType string `json:"media_type"`
|
||||
Manifest string `json:"manifest"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// StatsExport represents a stats record for export
|
||||
type StatsExport struct {
|
||||
Repository string `json:"repository"`
|
||||
PullCount int64 `json:"pull_count"`
|
||||
PushCount int64 `json:"push_count"`
|
||||
LastPull string `json:"last_pull,omitempty"`
|
||||
LastPush string `json:"last_push,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Returns:
|
||||
// - 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
|
||||
// - Whether the user is the hold captain
|
||||
//
|
||||
// Authentication: Requires valid service token from user's PDS
|
||||
func (h *XRPCHandler) HandleExportUserData(w http.ResponseWriter, r *http.Request) {
|
||||
// Get authenticated user from context
|
||||
user := getUserFromContext(r)
|
||||
if user == nil {
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("GDPR data export requested",
|
||||
"requester_did", user.DID,
|
||||
"hold_did", h.pds.DID())
|
||||
|
||||
export := HoldUserDataExport{
|
||||
ExportedAt: time.Now().UTC(),
|
||||
HoldDID: h.pds.DID(),
|
||||
UserDID: user.DID,
|
||||
LayerRecords: []LayerExport{},
|
||||
StatsRecords: []StatsExport{},
|
||||
}
|
||||
|
||||
// Check if user is captain
|
||||
_, captain, err := h.pds.GetCaptainRecord(r.Context())
|
||||
if err == nil && captain != nil && captain.Owner == user.DID {
|
||||
export.IsCaptain = true
|
||||
}
|
||||
|
||||
// Get crew record for user
|
||||
_, crewRecord, err := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
|
||||
if err == nil && crewRecord != nil {
|
||||
export.CrewRecord = &CrewExport{
|
||||
Role: crewRecord.Role,
|
||||
Permissions: crewRecord.Permissions,
|
||||
Tier: crewRecord.Tier,
|
||||
AddedAt: crewRecord.AddedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Get layer records for user
|
||||
layerRecords, err := h.pds.ListLayerRecordsForUser(r.Context(), user.DID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get layer records for export",
|
||||
"user_did", user.DID,
|
||||
"error", err)
|
||||
// Continue with empty list - don't fail entire export
|
||||
} else {
|
||||
for _, layer := range layerRecords {
|
||||
export.LayerRecords = append(export.LayerRecords, LayerExport{
|
||||
Digest: layer.Digest,
|
||||
Size: layer.Size,
|
||||
MediaType: layer.MediaType,
|
||||
Manifest: layer.Manifest,
|
||||
CreatedAt: layer.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get stats records for user
|
||||
statsRecords, err := h.pds.ListStatsRecordsForUser(r.Context(), user.DID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get stats records for export",
|
||||
"user_did", user.DID,
|
||||
"error", err)
|
||||
// Continue with empty list - don't fail entire export
|
||||
} else {
|
||||
for _, stat := range statsRecords {
|
||||
export.StatsRecords = append(export.StatsRecords, StatsExport{
|
||||
Repository: stat.Repository,
|
||||
PullCount: stat.PullCount,
|
||||
PushCount: stat.PushCount,
|
||||
LastPull: stat.LastPull,
|
||||
LastPush: stat.LastPush,
|
||||
UpdatedAt: stat.UpdatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
render.JSON(w, r, export)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) {
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
@@ -116,7 +116,7 @@ func setupTestXRPCHandlerWithIndex(t *testing.T) (*XRPCHandler, context.Context)
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
@@ -1986,7 +1986,7 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockS3Service,
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
|
||||
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
|
||||
|
||||
// Restore stdout
|
||||
w.Close()
|
||||
@@ -2429,7 +2429,7 @@ func TestRequireOwnerOrCrewAdmin_Authorized(t *testing.T) {
|
||||
|
||||
// Clean up - recreate captain record if it was deleted
|
||||
if w.Code == http.StatusOK {
|
||||
handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "")
|
||||
handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "", "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user