package pds import ( "context" "fmt" "atcr.io/pkg/atproto" "atcr.io/pkg/hold/quota" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/bluesky-social/indigo/repo" ) // CreateLayerRecord creates a new layer record in the hold's PDS // Returns the rkey and CID of the created record func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.LayerRecord) (string, string, error) { // Validate record if record.Type != atproto.LayerCollection { return "", "", fmt.Errorf("invalid record type: %s", record.Type) } if record.Digest == "" { return "", "", fmt.Errorf("digest is required") } if record.Size <= 0 { return "", "", fmt.Errorf("size must be positive") } // Create record with auto-generated TID rkey rkey, recordCID, err := p.repomgr.CreateRecord( ctx, p.uid, atproto.LayerCollection, record, ) if err != nil { return "", "", fmt.Errorf("failed to create layer record: %w", err) } return rkey, recordCID.String(), nil } // GetLayerRecord retrieves a specific layer record by rkey // Note: This is a simplified implementation. For production, you may need to pass the CID func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.LayerRecord, error) { // For now, we don't implement this as it's not needed for the manifest post feature // Full implementation would require querying the carstore with a specific CID return nil, fmt.Errorf("GetLayerRecord not yet implemented - use via XRPC listRecords instead") } // DeleteLayerRecord deletes a layer record by rkey // This deletes from both the repo (MST) and the records index func (p *HoldPDS) DeleteLayerRecord(ctx context.Context, rkey string) error { // Delete from repo (MST) if err := p.repomgr.DeleteRecord(ctx, p.uid, atproto.LayerCollection, rkey); err != nil { return fmt.Errorf("failed to delete from repo: %w", err) } // Delete from index if p.recordsIndex != nil { if err := p.recordsIndex.DeleteRecord(atproto.LayerCollection, rkey); err != nil { // Log but don't fail - index will resync on backfill fmt.Printf("Warning: failed to delete from records index: %v\n", err) } } return nil } // ListLayerRecords lists layer records with pagination // Returns records, next cursor (empty if no more), and error // Note: This is a simplified implementation. For production, consider adding filters // (by repository, user, digest, etc.) and proper pagination func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.LayerRecord, string, error) { // For now, return empty list - full implementation would query the carstore // This would require iterating over records in the collection and filtering // In practice, layer records are mainly for analytics and Bluesky posts, // not for runtime queries return nil, "", fmt.Errorf("ListLayerRecords not yet implemented") } // QuotaStats represents storage quota information for a user type QuotaStats struct { UserDID string `json:"userDid"` UniqueBlobs int `json:"uniqueBlobs"` TotalSize int64 `json:"totalSize"` Limit *int64 `json:"limit,omitempty"` // nil = unlimited Tier string `json:"tier,omitempty"` // quota tier (e.g., 'deckhand', 'bosun', 'quartermaster') } // GetQuotaForUser calculates storage quota for a specific user. // Uses SQL aggregation over the denormalized digest/size columns in the records index. func (p *HoldPDS) GetQuotaForUser(ctx context.Context, userDID string) (*QuotaStats, error) { if p.recordsIndex == nil { return nil, fmt.Errorf("records index not available") } uniqueBlobs, totalSize, err := p.recordsIndex.QuotaForDID(atproto.LayerCollection, userDID) if err != nil { return nil, fmt.Errorf("failed to query quota: %w", err) } return &QuotaStats{ UserDID: userDID, UniqueBlobs: uniqueBlobs, TotalSize: totalSize, }, nil } // GetAllUserQuotas returns quota stats for all users in a single SQL query. // Used by admin endpoints to avoid N+1 per-user quota lookups. func (p *HoldPDS) GetAllUserQuotas(ctx context.Context) (map[string]*QuotaStats, error) { if p.recordsIndex == nil { return nil, fmt.Errorf("records index not available") } quotas, err := p.recordsIndex.QuotasByDID(atproto.LayerCollection) if err != nil { return nil, fmt.Errorf("failed to query all quotas: %w", err) } result := make(map[string]*QuotaStats, len(quotas)) for did, q := range quotas { result[did] = &QuotaStats{ UserDID: did, UniqueBlobs: q.UniqueBlobs, TotalSize: q.TotalSize, } } return result, nil } // GetQuotaForUserWithTier calculates quota with tier-aware limits // It returns the base quota stats plus the tier limit and tier name. // Captain (owner) always has unlimited quota. func (p *HoldPDS) GetQuotaForUserWithTier(ctx context.Context, userDID string, quotaMgr *quota.Manager) (*QuotaStats, error) { // Get base stats stats, err := p.GetQuotaForUser(ctx, userDID) if err != nil { return nil, err } // If quota manager is nil or disabled, return unlimited if quotaMgr == nil || !quotaMgr.IsEnabled() { return stats, nil } // Check if user is captain (owner) - always unlimited _, captain, err := p.GetCaptainRecord(ctx) if err == nil && captain.Owner == userDID { stats.Tier = "owner" // Limit remains nil (unlimited) return stats, nil } // Get crew record to find tier crewTier := p.getCrewTier(ctx, userDID) // Resolve limit from quota manager stats.Limit = quotaMgr.GetTierLimit(crewTier) stats.Tier = quotaMgr.GetTierName(crewTier) return stats, nil } // getCrewTier returns the tier for a crew member, or empty string if not found func (p *HoldPDS) getCrewTier(ctx context.Context, userDID string) string { crewMembers, err := p.ListCrewMembers(ctx) if err != nil { return "" } for _, member := range crewMembers { if member.Record.Member == userDID { return member.Record.Tier } } 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 layer records for this user via the index (filtered by DID in SQL) cursor := "" batchSize := 1000 for { indexRecords, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.LayerCollection, userDID, batchSize, cursor) if err != nil { return nil, fmt.Errorf("failed to list layer records: %w", err) } for _, rec := range indexRecords { recordPath := rec.Collection + "/" + rec.Rkey _, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath) if err != nil { continue } recordValue, err := lexutil.CborDecodeValue(*recBytes) if err != nil { continue } layerRecord, ok := recordValue.(*atproto.LayerRecord) if !ok { continue } records = append(records, layerRecord) } if nextCursor == "" { break } cursor = nextCursor } if records == nil { records = []*atproto.LayerRecord{} } return records, nil }