mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
381 lines
11 KiB
Go
381 lines
11 KiB
Go
package pds
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/hold/quota"
|
|
indigoatproto "github.com/bluesky-social/indigo/api/atproto"
|
|
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
|
|
}
|
|
|
|
// BatchCreateLayerRecords creates multiple layer records in a single repo commit.
|
|
// This produces one firehose event instead of one per record.
|
|
// Invalid records are skipped with a warning. Returns the number of records created.
|
|
func (p *HoldPDS) BatchCreateLayerRecords(ctx context.Context, records []*atproto.LayerRecord) (int, error) {
|
|
var writes []*indigoatproto.RepoApplyWrites_Input_Writes_Elem
|
|
|
|
for _, record := range records {
|
|
if record.Type != atproto.LayerCollection {
|
|
slog.Warn("Skipping invalid record type in batch", "type", record.Type)
|
|
continue
|
|
}
|
|
if record.Digest == "" {
|
|
slog.Warn("Skipping record with empty digest in batch")
|
|
continue
|
|
}
|
|
if record.Size <= 0 {
|
|
slog.Warn("Skipping record with non-positive size in batch", "size", record.Size)
|
|
continue
|
|
}
|
|
|
|
writes = append(writes, &indigoatproto.RepoApplyWrites_Input_Writes_Elem{
|
|
RepoApplyWrites_Create: &indigoatproto.RepoApplyWrites_Create{
|
|
Collection: atproto.LayerCollection,
|
|
Value: &lexutil.LexiconTypeDecoder{Val: record},
|
|
},
|
|
})
|
|
}
|
|
|
|
if len(writes) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
if err := p.repomgr.BatchWrite(ctx, p.uid, writes); err != nil {
|
|
return 0, fmt.Errorf("batch write failed: %w", err)
|
|
}
|
|
|
|
return len(writes), 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")
|
|
}
|
|
|
|
// UpdateLayerRecord updates an existing layer record by rkey.
|
|
func (p *HoldPDS) UpdateLayerRecord(ctx context.Context, rkey string, record *atproto.LayerRecord) error {
|
|
_, err := p.repomgr.UpdateRecord(ctx, p.uid, atproto.LayerCollection, rkey, record)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update layer record: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ListLayerRecordsForManifest returns all layer records for a specific manifest AT-URI.
|
|
func (p *HoldPDS) ListLayerRecordsForManifest(ctx context.Context, manifestURI string) ([]*atproto.LayerRecord, error) {
|
|
if p.recordsIndex == nil {
|
|
return nil, fmt.Errorf("records index not available")
|
|
}
|
|
|
|
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() {
|
|
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
|
|
seen := make(map[string]int) // digest → index in records slice
|
|
cursor := ""
|
|
batchSize := 1000
|
|
|
|
for {
|
|
indexRecords, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, false)
|
|
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
|
|
}
|
|
|
|
if layerRecord.Manifest == manifestURI {
|
|
if _, exists := seen[layerRecord.Digest]; !exists {
|
|
seen[layerRecord.Digest] = len(records)
|
|
records = append(records, layerRecord)
|
|
}
|
|
}
|
|
}
|
|
|
|
if nextCursor == "" {
|
|
break
|
|
}
|
|
cursor = nextCursor
|
|
}
|
|
|
|
if records == nil {
|
|
records = []*atproto.LayerRecord{}
|
|
}
|
|
|
|
return records, nil
|
|
}
|