Files
at-container-registry/pkg/hold/pds/xrpc.go
T
Evan JarrettandClaude Fable 5.1 034ea5988b hold: report blob size on read presigns so the appview can skip its S3 HEAD
distribution calls Stat before every blob GET and HEAD. The appview's
Stat asked the hold for a presigned HEAD URL and then HEADed S3 with it
purely to read Content-Length for the descriptor: two round trips to
learn one number.

The hold's getBlob response for OCI digests on GET and HEAD now carries
"size". It comes from the records index when a layer record exists (a
SQLite lookup on a new digest index, no network) and from a HeadObject
otherwise, which is where config blobs land. If storage says the object
does not exist the hold answers 404 instead of signing a URL that can
only fail. The PUT and ATProto CID paths are untouched.

The appview builds the descriptor from the reported size and makes no
S3 request. When the field is absent it HEADs the presigned URL as
before, so a new appview works against a hold that has not been
upgraded, and an old appview ignores the extra field. A hold 404 maps
to ErrBlobUnknown.

Tests prove the index answered by leaving the mock bucket empty and
counting zero HeadObject calls, prove the fallback with exactly one, and
count requests reaching the fake S3 origin on the appview side rather
than trusting the returned size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5
2026-09-09 09:31:16 -05:00

2308 lines
76 KiB
Go

package pds
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"atcr.io/pkg/atproto"
"atcr.io/pkg/atproto/did"
"atcr.io/pkg/hold/quota"
"atcr.io/pkg/s3"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/bluesky-social/indigo/repo"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/gorilla/websocket"
"github.com/ipfs/go-cid"
"github.com/ipld/go-car"
carutil "github.com/ipld/go-car/util"
"crypto/sha256"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/multiformats/go-multihash"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
)
// XRPC handler for ATProto endpoints
// Context keys for storing user info in request context
type contextKey string
const (
contextKeyUser contextKey = "user"
)
// XRPCHandler handles XRPC requests for the embedded PDS
type XRPCHandler struct {
pds *HoldPDS
s3Service s3.S3Service
broadcaster *EventBroadcaster
scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners
httpClient HTTPClient // For testing - allows injecting mock HTTP client
quotaMgr *quota.Manager // Quota manager for tier-based limits
appviewDID string // DID of the trusted appview (for tier updates)
}
// PartInfo represents a completed part in a multipart upload
type PartInfo struct {
PartNumber int `json:"partNumber"`
ETag string `json:"etag"`
}
// PartUploadInfo contains structured information for uploading a part
// Used for both S3 presigned URLs and buffered mode with headers
type PartUploadInfo struct {
URL string `json:"url"` // URL to PUT the part to
Method string `json:"method,omitempty"` // HTTP method (usually "PUT")
Headers map[string]string `json:"headers,omitempty"` // Additional headers required for the request
}
// NewXRPCHandler creates a new XRPC handler
func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, broadcaster *EventBroadcaster, httpClient HTTPClient, quotaMgr *quota.Manager) *XRPCHandler {
return &XRPCHandler{
pds: pds,
s3Service: s3Service,
broadcaster: broadcaster,
httpClient: httpClient,
quotaMgr: quotaMgr,
}
}
// SetAppviewDID sets the trusted appview DID for tier update authentication.
func (h *XRPCHandler) SetAppviewDID(did string) {
h.appviewDID = did
}
// SetScanBroadcaster sets the scan broadcaster for dispatching scan jobs to scanners
func (h *XRPCHandler) SetScanBroadcaster(sb *ScanBroadcaster) {
h.scanBroadcaster = sb
}
// CORSMiddleware returns a simple CORS middleware configured for ATProto
// This should be applied in the main router before registering any routes
func (h *XRPCHandler) CORSMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set CORS headers for all requests
origin := r.Header.Get("Origin")
if origin == "" {
origin = "*"
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Expose-Headers", "*")
w.Header().Set("Access-Control-Max-Age", "300")
// Handle OPTIONS preflight
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}
// requireOwnerOrCrewAdmin middleware - validates owner or crew admin access
// Stores validated user in request context
func (h *XRPCHandler) requireOwnerOrCrewAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := ValidateOwnerOrCrewAdmin(r, h.pds, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("unauthorized: %v", err), http.StatusForbidden)
return
}
// Store user in context for handlers to access
ctx := context.WithValue(r.Context(), contextKeyUser, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requireAuth middleware - validates service token authentication
// Stores validated user in request context
func (h *XRPCHandler) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Service token authentication
user, err := ValidateServiceToken(r, h.pds.did, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("unauthorized: %v", err), http.StatusUnauthorized)
return
}
// Store user in context for handlers to access
ctx := context.WithValue(r.Context(), contextKeyUser, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// getUserFromContext extracts the authenticated user from request context
// Returns nil if no user is in context (handler should be protected by auth middleware)
func getUserFromContext(r *http.Request) *ValidatedUser {
user, ok := r.Context().Value(contextKeyUser).(*ValidatedUser)
if !ok {
return nil
}
return user
}
// RegisterHandlers registers all XRPC endpoints using chi router
// Note: CORS middleware must be applied in the main router before calling this
func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
// Public read-only endpoints (no auth)
r.Group(func(r chi.Router) {
// Health and server info
r.Get("/xrpc/_health", h.HandleHealth)
r.Get(atproto.ServerDescribeServer, h.HandleDescribeServer)
// Repository metadata
r.Get(atproto.RepoDescribeRepo, h.HandleDescribeRepo)
r.Get(atproto.RepoGetRecord, h.HandleGetRecord)
r.Get(atproto.RepoListRecords, h.HandleListRecords)
r.Get(atproto.HoldGetLayersForManifest, h.HandleGetLayersForManifest)
r.Get(atproto.HoldGetImageConfig, h.HandleGetImageConfig)
// Sync endpoints
r.Get(atproto.SyncListBlobs, h.HandleListBlobs)
r.Get(atproto.SyncListRepos, h.HandleListRepos)
r.Get(atproto.SyncGetRecord, h.HandleSyncGetRecord)
r.Get(atproto.SyncGetRepo, h.HandleGetRepo)
r.Get(atproto.SyncGetRepoStatus, h.HandleGetRepoStatus)
r.Get(atproto.SyncGetLatestCommit, h.HandleGetLatestCommit)
r.Get(atproto.SyncSubscribeRepos, h.HandleSubscribeRepos)
// DID document and handle resolution
r.Get("/.well-known/did.json", h.HandleDIDDocument)
r.Get("/.well-known/atproto-did", h.HandleAtprotoDID)
// Identity and profile endpoints
r.Get(atproto.IdentityResolveHandle, h.HandleResolveHandle)
r.Get(atproto.ActorGetProfile, h.HandleGetProfile)
r.Get(atproto.ActorGetProfiles, h.HandleGetProfiles)
})
// Blob read endpoints (conditional auth based on captain.public)
// Auth is handled inside HandleGetBlob
r.Group(func(r chi.Router) {
r.Get(atproto.SyncGetBlob, h.HandleGetBlob)
r.Head(atproto.SyncGetBlob, h.HandleGetBlob)
})
// Write endpoints (owner/crew admin auth)
r.Group(func(r chi.Router) {
r.Use(h.requireOwnerOrCrewAdmin)
r.Post(atproto.RepoDeleteRecord, h.HandleDeleteRecord)
r.Post(atproto.RepoUploadBlob, h.HandleUploadBlob)
})
// Manifest purge: auth happens inside the handler because it needs the
// manifest URI from the request body to allow the manifest's owner to
// purge their own records (in addition to captain / crew:admin).
r.Post(atproto.HoldPurgeManifest, h.HandlePurgeManifest)
// Auth-only endpoints (DPoP auth)
r.Group(func(r chi.Router) {
r.Use(h.requireAuth)
r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew)
// GDPR data export endpoint
r.Get(atproto.HoldExportUserData, h.HandleExportUserData)
// GDPR data deletion endpoint
r.Delete(atproto.HoldDeleteUserData, h.HandleDeleteUserData)
})
// Public quota endpoint (no auth - quota is per-user, just needs userDid param)
r.Get(atproto.HoldGetQuota, h.HandleGetQuota)
// Public tier list endpoint (no auth)
r.Get(atproto.HoldListTiers, h.HandleListTiers)
// Appview-authenticated endpoints (appview JWT auth)
r.Post(atproto.HoldUpdateCrewTier, h.HandleUpdateCrewTier)
// Scanner WebSocket endpoint (shared secret auth)
r.Get(atproto.HoldSubscribeScanJobs, h.HandleSubscribeScanJobs)
}
// HandleHealth returns health check information
func (h *XRPCHandler) HandleHealth(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, map[string]any{
"version": "0.4.999",
})
}
// HandleDescribeServer returns server metadata
func (h *XRPCHandler) HandleDescribeServer(w http.ResponseWriter, r *http.Request) {
// Extract hostname from public URL for availableUserDomains
// For hold01.atcr.io, return [".hold01.atcr.io"] to match stream.place pattern
hostname := h.pds.PublicURL
hostname = strings.TrimPrefix(hostname, "http://")
hostname = strings.TrimPrefix(hostname, "https://")
hostname, _, _ = strings.Cut(hostname, "/") // Remove path
hostname, _, _ = strings.Cut(hostname, ":") // Remove port
render.JSON(w, r, map[string]any{
"did": h.pds.DID(),
"availableUserDomains": []string{"." + hostname},
"inviteCodeRequired": true, // Single-user PDS, no account creation
})
}
// HandleResolveHandle resolves a handle to a DID
func (h *XRPCHandler) HandleResolveHandle(w http.ResponseWriter, r *http.Request) {
// Get handle parameter
handle := r.URL.Query().Get("handle")
if handle == "" {
http.Error(w, "handle parameter required", http.StatusBadRequest)
return
}
// For this hold PDS, the handle is the domain part of the DID
// e.g., "hold01.atcr.io did:web:hold01.atcr.io"
expectedHandle := didWebHandle(h.pds.DID())
// Check if the handle matches
if handle != expectedHandle {
http.Error(w, "handle not found", http.StatusNotFound)
return
}
// Return the DID
render.JSON(w, r, map[string]string{
"did": h.pds.DID(),
})
}
// HandleGetProfile returns aggregated profile information
func (h *XRPCHandler) HandleGetProfile(w http.ResponseWriter, r *http.Request) {
// Get actor parameter (can be DID or handle)
actor := r.URL.Query().Get("actor")
if actor == "" {
http.Error(w, "actor parameter required", http.StatusBadRequest)
return
}
// Normalize actor to DID
actorDID := actor
if _, err := syntax.ParseDID(actor); err != nil {
// It's a handle, resolve to DID
expectedHandle := didWebHandle(h.pds.DID())
if actor == expectedHandle {
actorDID = h.pds.DID()
} else {
http.Error(w, "actor not found", http.StatusNotFound)
return
}
}
// Verify it's this hold's DID
if actorDID != h.pds.DID() {
http.Error(w, "actor not found", http.StatusNotFound)
return
}
// Build profile response using shared function
render.JSON(w, r, h.buildProfileResponse(r.Context()))
}
// HandleGetProfiles returns aggregated profile information for multiple actors
func (h *XRPCHandler) HandleGetProfiles(w http.ResponseWriter, r *http.Request) {
// Get actors parameters (can be multiple)
actors := r.URL.Query()["actors"]
if len(actors) == 0 {
http.Error(w, "actors parameter required", http.StatusBadRequest)
return
}
// Initialize profiles array (always return array, even if empty)
profiles := []map[string]any{}
// Expected handle for this hold
expectedHandle := didWebHandle(h.pds.DID())
// Check each actor to see if it matches this hold's DID
for _, actor := range actors {
// Normalize actor to DID
actorDID := actor
if _, err := syntax.ParseDID(actor); err != nil {
// It's a handle, check if it matches
if actor == expectedHandle {
actorDID = h.pds.DID()
} else {
// Not this hold's handle, skip
continue
}
}
// Check if it's this hold's DID
if actorDID != h.pds.DID() {
// Not this hold, skip
continue
}
// Build profile for this hold
profile := h.buildProfileResponse(r.Context())
if profile != nil {
profiles = append(profiles, profile)
}
}
// Return profiles array
render.JSON(w, r, map[string]any{
"profiles": profiles,
})
}
// buildProfileResponse builds a profile response map (shared by GetProfile and GetProfiles)
func (h *XRPCHandler) buildProfileResponse(ctx context.Context) map[string]any {
// Get profile record from repo
_, profileVal, _ := h.pds.repomgr.GetRecord(
ctx,
h.pds.uid,
"app.bsky.actor.profile",
"self",
cid.Undef,
)
// Base response with minimal info
response := map[string]any{
"did": h.pds.DID(),
"handle": didWebHandle(h.pds.DID()),
"postsCount": 0,
"followersCount": 0,
"followsCount": 0,
}
// Count posts
session, err := h.pds.carstore.ReadOnlySession(h.pds.uid)
if err == nil {
head, err := h.pds.carstore.GetUserRepoHead(ctx, h.pds.uid)
if err == nil && head.Defined() {
repoHandle, err := repo.OpenRepo(ctx, session, head)
if err == nil {
postCount := 0
_ = repoHandle.ForEach(ctx, atproto.BskyPostCollection, func(k string, v cid.Cid) error {
postCount++
return nil
})
response["postsCount"] = postCount
}
}
}
// Add profile fields if profile record exists
if err == nil {
profileRecord, ok := profileVal.(*bsky.ActorProfile)
if ok {
if profileRecord.DisplayName != nil && *profileRecord.DisplayName != "" {
response["displayName"] = *profileRecord.DisplayName
}
if profileRecord.Description != nil && *profileRecord.Description != "" {
response["description"] = *profileRecord.Description
}
if profileRecord.Avatar != nil {
avatarURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
h.pds.PublicURL, h.pds.DID(), profileRecord.Avatar.Ref.String())
response["avatar"] = avatarURL
}
}
}
return response
}
// HandleDescribeRepo returns repository information
func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request) {
// Get repo parameter
repoDID := r.URL.Query().Get("repo")
if repoDID == "" || repoDID != h.pds.DID() {
http.Error(w, "invalid repo", http.StatusBadRequest)
return
}
// Generate DID document
didDoc, err := did.BuildDIDDocument(h.pds.DID(), h.pds.PublicURL, h.pds.SigningKey(), "atproto", HoldServices(h.pds.PublicURL))
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
return
}
// Get actual collections from repo
collections, err := h.pds.ListCollections(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to list collections: %v", err), http.StatusInternalServerError)
return
}
// Note: For did:web, the handle IS the DID (not just hostname)
render.JSON(w, r, map[string]any{
"did": h.pds.DID(),
"handle": h.pds.DID(),
"didDoc": didDoc,
"collections": collections,
"handleIsCorrect": true,
})
}
// HandleGetRecord retrieves a record from the repository
func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) {
repoDID := r.URL.Query().Get("repo")
collection := r.URL.Query().Get("collection")
rkey := r.URL.Query().Get("rkey")
if repoDID == "" || collection == "" || rkey == "" {
http.Error(w, "missing required parameters", http.StatusBadRequest)
return
}
if repoDID != h.pds.DID() {
http.Error(w, "invalid repo", http.StatusBadRequest)
return
}
// Use generic repomgr.GetRecord - works for any collection
// lexutil type registry automatically unmarshals to correct type
recordCID, recordValue, err := h.pds.repomgr.GetRecord(
r.Context(),
h.pds.uid,
collection,
rkey,
cid.Undef,
)
if err != nil {
if strings.Contains(err.Error(), "not found") {
http.Error(w, "record not found", http.StatusNotFound)
} else {
http.Error(w, fmt.Sprintf("failed to get record: %v", err), http.StatusInternalServerError)
}
return
}
render.JSON(w, r, map[string]any{
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey),
"cid": recordCID.String(),
"value": recordValue,
})
}
// HandleGetLayersForManifest returns layer records for a specific manifest AT-URI.
func (h *XRPCHandler) HandleGetLayersForManifest(w http.ResponseWriter, r *http.Request) {
manifestURI := r.URL.Query().Get("manifest")
if manifestURI == "" {
http.Error(w, `{"error":"InvalidRequest","message":"manifest parameter is required"}`, http.StatusBadRequest)
return
}
records, err := h.pds.ListLayerRecordsForManifest(r.Context(), manifestURI)
if err != nil {
slog.Error("Failed to list layer records for manifest", "error", err, "manifest", manifestURI)
http.Error(w, `{"error":"InternalServerError","message":"failed to list layer records"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"layers": records,
}); err != nil {
slog.Error("Failed to encode layer records response", "error", err)
}
}
// HandleGetImageConfig returns the OCI image config record for a manifest digest.
func (h *XRPCHandler) HandleGetImageConfig(w http.ResponseWriter, r *http.Request) {
digest := r.URL.Query().Get("digest")
if digest == "" {
http.Error(w, `{"error":"InvalidRequest","message":"digest parameter is required"}`, http.StatusBadRequest)
return
}
_, record, err := h.pds.GetImageConfigRecord(r.Context(), digest)
if err != nil {
http.Error(w, `{"error":"NotFound","message":"image config not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(record); err != nil {
slog.Error("Failed to encode image config response", "error", err)
}
}
// HandleListRecords lists records in a collection
// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-list-records
// Supports pagination via limit, cursor, and reverse parameters
// Uses SQL index for efficient pagination (following official ATProto PDS pattern)
func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request) {
repoDID := r.URL.Query().Get("repo")
collection := r.URL.Query().Get("collection")
if repoDID == "" || collection == "" {
http.Error(w, "missing required parameters", http.StatusBadRequest)
return
}
if repoDID != h.pds.DID() {
http.Error(w, "invalid repo", http.StatusBadRequest)
return
}
// Parse pagination parameters (per spec)
limit := 50 // default
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
parsedLimit, err := strconv.Atoi(limitStr)
if err != nil || parsedLimit < 1 || parsedLimit > 100 {
http.Error(w, "invalid limit (must be 1-100)", http.StatusBadRequest)
return
}
limit = parsedLimit
}
cursor := r.URL.Query().Get("cursor")
reverse := r.URL.Query().Get("reverse") == "true"
// Use records index if available (efficient SQL-based pagination)
if h.pds.recordsIndex != nil {
h.handleListRecordsIndexed(w, r, collection, limit, cursor, reverse)
return
}
// Fallback: MST-based listing (legacy path for tests or in-memory mode)
h.handleListRecordsMST(w, r, collection, limit, cursor, reverse)
}
// handleListRecordsIndexed uses the SQL records index for efficient pagination
func (h *XRPCHandler) handleListRecordsIndexed(w http.ResponseWriter, r *http.Request, collection string, limit int, cursor string, reverse bool) {
// Query the index
indexedRecords, nextCursor, err := h.pds.recordsIndex.ListRecords(collection, limit, cursor, reverse)
if err != nil {
slog.Error("Failed to list records from index", "error", err, "collection", collection)
http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
return
}
// Create session to fetch full record data
session, err := h.pds.carstore.ReadOnlySession(h.pds.uid)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
return
}
head, err := h.pds.carstore.GetUserRepoHead(r.Context(), h.pds.uid)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get repo head: %v", err), http.StatusInternalServerError)
return
}
if !head.Defined() {
// Empty repo, return empty list
render.JSON(w, r, map[string]any{"records": []any{}})
return
}
repoHandle, err := repo.OpenRepo(r.Context(), session, head)
if err != nil {
http.Error(w, fmt.Sprintf("failed to open repo: %v", err), http.StatusInternalServerError)
return
}
// Fetch full record data for each indexed record
records := []map[string]any{}
for _, rec := range indexedRecords {
// Construct the record path
recordPath := rec.Collection + "/" + rec.Rkey
// Get the record bytes
recordCID, recBytes, err := repoHandle.GetRecordBytes(r.Context(), recordPath)
if err != nil {
slog.Warn("Failed to get indexed record, skipping", "path", recordPath, "error", err)
continue
}
// Decode using lexutil (type registry handles unmarshaling)
recordValue, err := lexutil.CborDecodeValue(*recBytes)
if err != nil {
slog.Warn("Failed to decode indexed record, skipping", "path", recordPath, "error", err)
continue
}
records = append(records, map[string]any{
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), rec.Collection, rec.Rkey),
"cid": recordCID.String(),
"value": recordValue,
})
}
response := map[string]any{
"records": records,
}
// Include cursor in response if there are more records
if nextCursor != "" {
response["cursor"] = nextCursor
}
render.JSON(w, r, response)
}
// handleListRecordsMST uses the legacy MST-based listing (fallback for tests)
func (h *XRPCHandler) handleListRecordsMST(w http.ResponseWriter, r *http.Request, collection string, limit int, cursor string, reverse bool) {
// Generic implementation using repo.ForEach
session, err := h.pds.carstore.ReadOnlySession(h.pds.uid)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
return
}
head, err := h.pds.carstore.GetUserRepoHead(r.Context(), h.pds.uid)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get repo head: %v", err), http.StatusInternalServerError)
return
}
if !head.Defined() {
// Empty repo, return empty list
render.JSON(w, r, map[string]any{"records": []any{}})
return
}
repoHandle, err := repo.OpenRepo(r.Context(), session, head)
if err != nil {
http.Error(w, fmt.Sprintf("failed to open repo: %v", err), http.StatusInternalServerError)
return
}
// Collect all records in the collection first.
// MST only supports forward iteration, so for newest-first (default) we must
// collect all records, reverse, then apply cursor/limit.
allRecords := []map[string]any{}
err = repoHandle.ForEach(r.Context(), collection, func(k string, v cid.Cid) error {
// k is like "io.atcr.hold.captain/self" or "io.atcr.hold.crew/3m3by7msdln22"
parts := strings.Split(k, "/")
if len(parts) < 2 {
return nil // Skip invalid keys
}
// Extract actual collection and rkey from the key path
actualCollection := strings.Join(parts[:len(parts)-1], "/")
rkey := parts[len(parts)-1]
// Filter: only include records that match the requested collection
if actualCollection != collection {
return repo.ErrDoneIterating // Stop walking the tree
}
// Get the record bytes
recordCID, recBytes, err := repoHandle.GetRecordBytes(r.Context(), k)
if err != nil {
return fmt.Errorf("failed to get record: %v", err)
}
// Decode using lexutil (type registry handles unmarshaling)
recordValue, err := lexutil.CborDecodeValue(*recBytes)
if err != nil {
return fmt.Errorf("failed to decode record: %v", err)
}
allRecords = append(allRecords, map[string]any{
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), actualCollection, rkey),
"cid": recordCID.String(),
"value": recordValue,
"rkey": rkey,
})
return nil
})
if err != nil {
if err == repo.ErrDoneIterating || strings.Contains(err.Error(), "done iterating") {
// Successfully stopped at collection boundary
} else if strings.Contains(err.Error(), "not found") {
allRecords = []map[string]any{}
} else {
http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
return
}
}
// Default order is newest-first (reverse chronological).
// MST iterates oldest-first, so reverse for default order.
if !reverse && len(allRecords) > 0 {
for i, j := 0, len(allRecords)-1; i < j; i, j = i+1, j-1 {
allRecords[i], allRecords[j] = allRecords[j], allRecords[i]
}
}
// Apply cursor and limit
records := []map[string]any{}
var nextCursor string
skipUntilCursor := cursor != ""
for _, rec := range allRecords {
rkey := rec["rkey"].(string)
if skipUntilCursor {
if rkey == cursor {
skipUntilCursor = false
}
continue
}
if len(records) >= limit {
nextCursor = rkey
break
}
delete(rec, "rkey")
records = append(records, rec)
}
if skipUntilCursor {
records = []map[string]any{}
nextCursor = ""
}
response := map[string]any{
"records": records,
}
if nextCursor != "" {
response["cursor"] = nextCursor
}
render.JSON(w, r, response)
}
// HandleDeleteRecord deletes a record from the repository
// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-delete-record
// Accepts JSON input with repo, collection, rkey, and optional swap parameters
func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request) {
var err error
// Parse JSON body (per spec - input is in body, not query params)
var input struct {
Repo string `json:"repo"`
Collection string `json:"collection"`
Rkey string `json:"rkey"`
SwapRecord *string `json:"swapRecord,omitempty"` // Optional CID for compare-and-swap
SwapCommit *string `json:"swapCommit,omitempty"` // Optional CID for compare-and-swap
}
if err = json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON body: %v", err), http.StatusBadRequest)
return
}
if input.Repo == "" || input.Collection == "" || input.Rkey == "" {
http.Error(w, "missing required parameters", http.StatusBadRequest)
return
}
if input.Repo != h.pds.DID() {
http.Error(w, "invalid repo", http.StatusBadRequest)
return
}
// TODO: Implement swap record/commit validation
// For now, if swap parameters are provided, we should validate them
// against the current record/commit CID before deleting
if input.SwapRecord != nil || input.SwapCommit != nil {
// Parse swap CIDs
var swapRecordCID, swapCommitCID cid.Cid
if input.SwapRecord != nil {
swapRecordCID, err = cid.Decode(*input.SwapRecord)
if err != nil {
http.Error(w, "invalid swapRecord CID", http.StatusBadRequest)
return
}
}
if input.SwapCommit != nil {
swapCommitCID, err = cid.Decode(*input.SwapCommit)
if err != nil {
http.Error(w, "invalid swapCommit CID", http.StatusBadRequest)
return
}
}
// Validate swap conditions
if input.SwapRecord != nil {
// Get current record CID
currentCID, _, err := h.pds.repomgr.GetRecord(r.Context(), h.pds.uid, input.Collection, input.Rkey, cid.Undef)
if err != nil {
if strings.Contains(err.Error(), "not found") {
http.Error(w, "record not found", http.StatusNotFound)
} else {
http.Error(w, fmt.Sprintf("failed to get current record: %v", err), http.StatusInternalServerError)
}
return
}
if !currentCID.Equals(swapRecordCID) {
// Swap failed - record CID doesn't match
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]any{
"error": "InvalidSwap",
"message": "record CID does not match swapRecord",
})
return
}
}
// SwapCommit validation would require checking the repo head CID
// For now, we'll skip this as it's complex and not critical for MVP
_ = swapCommitCID
}
// Delete the record using repomgr
err = h.pds.repomgr.DeleteRecord(r.Context(), h.pds.uid, input.Collection, input.Rkey)
if err != nil {
if strings.Contains(err.Error(), "not found") {
http.Error(w, "record not found", http.StatusNotFound)
} else {
http.Error(w, fmt.Sprintf("failed to delete record: %v", err), http.StatusInternalServerError)
}
return
}
// Get commit info for response (per spec)
// The spec requires returning commit metadata
head, err := h.pds.carstore.GetUserRepoHead(r.Context(), h.pds.uid)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get repo head: %v", err), http.StatusInternalServerError)
return
}
rev, err := h.pds.repomgr.GetRepoRev(r.Context(), h.pds.uid)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get repo rev: %v", err), http.StatusInternalServerError)
return
}
// Return commit response (per spec)
render.JSON(w, r, map[string]any{
"commit": map[string]any{
"cid": head.String(),
"rev": rev,
},
})
}
// HandlePurgeManifest deletes layer, scan, and image-config records associated
// with a single manifest AT-URI. Idempotent. Auth (handled inline because it
// depends on the request body): captain, crew member with crew:admin, or the
// manifest's owner if they are currently crew. The manifest record itself
// lives in the user's PDS and is not affected. S3 blobs are not removed; the
// GC handles those based on remaining references and the labeler grace window.
func (h *XRPCHandler) HandlePurgeManifest(w http.ResponseWriter, r *http.Request) {
var input struct {
ManifestURI string `json:"manifestUri"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON body: %v", err), http.StatusBadRequest)
return
}
if input.ManifestURI == "" {
http.Error(w, "manifestUri is required", http.StatusBadRequest)
return
}
if !strings.HasPrefix(input.ManifestURI, "at://") {
http.Error(w, "manifestUri must be an at:// URI", http.StatusBadRequest)
return
}
if _, err := ValidateManifestPurger(r, h.pds, h.httpClient, input.ManifestURI); err != nil {
http.Error(w, fmt.Sprintf("unauthorized: %v", err), http.StatusForbidden)
return
}
res, err := h.pds.PurgeManifestRecords(r.Context(), input.ManifestURI)
if err != nil {
http.Error(w, fmt.Sprintf("purge failed: %v", err), http.StatusInternalServerError)
return
}
render.JSON(w, r, map[string]any{
"success": true,
"layersDeleted": res.LayersDeleted,
"scanDeleted": res.ScanDeleted,
"imageConfigDeleted": res.ImageConfigDeleted,
})
}
// HandleSyncGetRecord returns a single record as a CAR file for sync
func (h *XRPCHandler) HandleSyncGetRecord(w http.ResponseWriter, r *http.Request) {
did := r.URL.Query().Get("did")
collection := r.URL.Query().Get("collection")
rkey := r.URL.Query().Get("rkey")
if did == "" || collection == "" || rkey == "" {
http.Error(w, "missing required parameters", http.StatusBadRequest)
return
}
if did != h.pds.DID() {
http.Error(w, "invalid did", http.StatusBadRequest)
return
}
// Use repomgr to get record proof (repo head + all blocks in MST path to record)
repoHead, blocks, err := h.pds.repomgr.GetRecordProof(r.Context(), h.pds.uid, collection, rkey)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get record: %v", err), http.StatusNotFound)
return
}
// Write CAR file with all accessed blocks
w.Header().Set("Content-Type", "application/vnd.ipld.car")
// Create a buffer to write the CAR data
var buf bytes.Buffer
// Create CAR header with the repo head as root (not the record CID)
// The CAR file represents a slice of the repo from head to record
header := &car.CarHeader{
Roots: []cid.Cid{repoHead},
Version: 1,
}
// Write the CAR header
if err := car.WriteHeader(header, &buf); err != nil {
http.Error(w, fmt.Sprintf("failed to write CAR header: %v", err), http.StatusInternalServerError)
return
}
// Write all logged blocks to the CAR file
for _, blk := range blocks {
if err := carutil.LdWrite(&buf, blk.Cid().Bytes(), blk.RawData()); err != nil {
http.Error(w, fmt.Sprintf("failed to write block to CAR: %v", err), http.StatusInternalServerError)
return
}
}
// Write the CAR data to the response
if _, err := w.Write(buf.Bytes()); err != nil {
slog.Error("failed to write CAR to http response", "error", err, "path", r.URL.Path)
}
}
// HandleGetRepo returns the full repository as a CAR file
// This is the critical endpoint for relay crawling and Bluesky discovery
func (h *XRPCHandler) HandleGetRepo(w http.ResponseWriter, r *http.Request) {
// Get required 'did' parameter
did := r.URL.Query().Get("did")
if did == "" {
http.Error(w, "missing required parameter: did", http.StatusBadRequest)
return
}
// Validate DID matches this PDS
if did != h.pds.DID() {
http.Error(w, "repo not found", http.StatusNotFound)
return
}
// Get optional 'since' parameter for diff export
since := r.URL.Query().Get("since")
// Set CAR content type
w.Header().Set("Content-Type", "application/vnd.ipld.car")
// Stream the repository CAR file directly to the response
// ReadRepo handles full export or diff based on 'since' parameter
err := h.pds.repomgr.ReadRepo(r.Context(), h.pds.uid, since, w)
if err != nil {
// Error already written to response by ReadRepo streaming
// Log it but don't try to write another HTTP error
slog.Error("Error streaming repo CAR", "error", err)
return
}
}
// WebSocket upgrader
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// Allow all origins for MVP (ATProto firehose is public)
return true
},
}
// HandleSubscribeRepos handles WebSocket connections for the firehose
// This is the real-time event stream for repo changes
func (h *XRPCHandler) HandleSubscribeRepos(w http.ResponseWriter, r *http.Request) {
// Check if broadcaster is configured
if h.broadcaster == nil {
http.Error(w, "firehose not enabled", http.StatusNotImplemented)
return
}
// Get optional cursor parameter for backfill
// Default to -1 (no backfill, only stream new events)
// cursor=0 means "replay all events from the beginning"
var cursor int64 = -1
if cursorStr := r.URL.Query().Get("cursor"); cursorStr != "" {
var err error
cursor, err = strconv.ParseInt(cursorStr, 10, 64)
if err != nil {
http.Error(w, "invalid cursor parameter", http.StatusBadRequest)
return
}
}
// Upgrade to WebSocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
slog.Error("WebSocket upgrade failed", "error", err)
return
}
// Subscribe to events
// The broadcaster's handleSubscriber goroutine will manage this connection
// and handle cleanup when the client disconnects
h.broadcaster.Subscribe(conn, cursor, r.UserAgent())
}
// HandleSubscribeScanJobs handles WebSocket connections from scanners
// Scanners connect here to receive scan jobs and send back results
func (h *XRPCHandler) HandleSubscribeScanJobs(w http.ResponseWriter, r *http.Request) {
if h.scanBroadcaster == nil {
http.Error(w, "scanning not enabled", http.StatusNotImplemented)
return
}
// Authenticate via shared secret (query param or header)
secret := r.URL.Query().Get("secret")
if secret == "" {
secret = r.Header.Get("X-Scanner-Secret")
}
if !h.scanBroadcaster.ValidateScannerSecret(secret) {
http.Error(w, "invalid scanner secret", http.StatusUnauthorized)
return
}
// Get optional cursor for backfill
var cursor int64 = -1
if cursorStr := r.URL.Query().Get("cursor"); cursorStr != "" {
var err error
cursor, err = strconv.ParseInt(cursorStr, 10, 64)
if err != nil {
http.Error(w, "invalid cursor parameter", http.StatusBadRequest)
return
}
}
// How much scanning this process can run at once, and who it is.
//
// Both are optional: a scanner built before they existed sends neither,
// and the broadcaster then treats it as one worker with a
// per-connection identity — exactly the behaviour it had before. A
// malformed workers value is ignored rather than rejected, since the
// connection is still perfectly usable at the default.
workers := 0
if raw := r.URL.Query().Get("workers"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
workers = n
} else {
slog.Warn("Scanner declared an unusable worker count, using the default",
"workers", raw)
}
}
instanceID := r.URL.Query().Get("instance")
// Upgrade to WebSocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
slog.Error("Scanner WebSocket upgrade failed", "error", err)
return
}
h.scanBroadcaster.Subscribe(conn, cursor, instanceID, workers)
}
// ScanBroadcasterRef returns the scan broadcaster (used by OCI handler to enqueue jobs)
func (h *XRPCHandler) ScanBroadcasterRef() *ScanBroadcaster {
return h.scanBroadcaster
}
// HandleUploadBlob handles blob uploads with support for multipart operations
// Direct blob upload: POST with raw bytes (ATProto-compliant)
func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
// Get authenticated user from context (if coming through middleware)
// Otherwise validate directly (for tests or direct handler calls)
user := getUserFromContext(r)
if user == nil {
var err error
user, err = ValidateOwnerOrCrewAdmin(r, h.pds, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
return
}
}
// Use authenticated user's DID for ATProto blob storage (per-DID paths)
did := user.DID
// Read all data into memory to compute CID
// For large files, this should use multipart upload instead
blobData, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to read blob data: %v", err), http.StatusInternalServerError)
return
}
size := int64(len(blobData))
// Compute SHA-256 hash
hash := sha256.Sum256(blobData)
// Create CIDv1 with SHA-256 multihash
mh, err := multihash.EncodeName(hash[:], "sha2-256")
if err != nil {
http.Error(w, fmt.Sprintf("failed to encode multihash: %v", err), http.StatusInternalServerError)
return
}
// Create CIDv1 with raw codec (0x55)
// ATProto uses CIDv1 with raw codec for blobs
blobCID := cid.NewCidV1(0x55, mh)
// Store blob via S3 at ATProto path
path := atprotoBlobPath(did, blobCID.String())
if err := h.s3Service.PutBytes(r.Context(), path, blobData, "application/octet-stream"); err != nil {
http.Error(w, fmt.Sprintf("failed to put blob: %v", err), http.StatusInternalServerError)
return
}
// Return ATProto-compliant blob response
render.JSON(w, r, map[string]any{
"blob": map[string]any{
"$type": "blob",
"ref": map[string]any{
"$link": blobCID.String(),
},
"mimeType": "application/octet-stream",
"size": size,
},
})
}
// HandleGetBlob routes blob requests to appropriate handlers based on blob type
// Routes to:
// - handleGetOCIBlob for OCI image blobs (sha256:...)
// - handleGetATProtoBlob for ATProto blobs (CID format)
func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
did := r.URL.Query().Get("did")
cidOrDigest := r.URL.Query().Get("cid")
slog.Debug("HandleGetBlob request",
"method", r.Method,
"did", did,
"cid", cidOrDigest)
if did == "" || cidOrDigest == "" {
http.Error(w, "missing required parameters", http.StatusBadRequest)
return
}
// Route based on blob type
if strings.HasPrefix(cidOrDigest, "sha256:") {
// OCI blob (container image layers)
h.handleGetOCIBlob(w, r, did, cidOrDigest)
return
}
// ATProto blob (profile avatars, etc.)
h.handleGetATProtoBlob(w, r, did, cidOrDigest)
}
// handleGetOCIBlob handles OCI container image blob requests
// Returns JSON with presigned URL for AppView integration
// Authorization:
// - GET/HEAD: hold read access (captain.public, or crew with blob:read)
// - PUT: hold write access (captain, or crew with blob:write). A read verdict
// is not enough: on a public hold ValidateBlobReadAccess passes anonymous
// callers, and a presigned PUT is a write into the shared content-addressed
// layer space.
func (h *XRPCHandler) handleGetOCIBlob(w http.ResponseWriter, r *http.Request, did, digest string) {
slog.Debug("Processing OCI blob", "digest", digest)
// Validate the digest before it becomes an S3 key
if err := validateOCIDigest(digest); err != nil {
slog.Warn("Rejected OCI blob request with malformed digest", "error", err, "digest", digest)
http.Error(w, fmt.Sprintf("invalid cid: %v", err), http.StatusBadRequest)
return
}
// Determine presigned URL operation (GET, HEAD or PUT)
// Check for ?method= query parameter first (from AppView)
operation, err := parseBlobPresignOperation(r)
if err != nil {
slog.Warn("Rejected OCI blob request with unsupported method", "error", err, "digest", digest)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if operation == http.MethodPut {
// Write capability: gate on the hold's write model, same as the
// multipart upload endpoints.
if _, err := ValidateBlobWriteAccess(r, h.pds, h.httpClient); err != nil {
slog.Warn("OCI blob write authorization failed", "error", err, "digest", digest)
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
return
}
} else {
// Validate blob read access (hold access control)
// If captain.public = true, returns nil (public access allowed)
// If captain.public = false, validates auth and checks for blob:read permission
scannerSecret := ""
if h.scanBroadcaster != nil {
scannerSecret = h.scanBroadcaster.Secret()
}
if _, err := ValidateBlobReadAccess(r, h.pds, h.httpClient, scannerSecret); err != nil {
slog.Warn("OCI blob authorization failed", "error", err, "digest", digest)
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
return
}
}
// Resolve the blob's size for reads so the caller does not have to go to S3
// itself just to fill in a descriptor. A read presign is the AppView's Stat,
// and Stat used to cost a second round trip: the presigned HEAD URL was
// fetched here and then HEADed against S3 purely to read Content-Length.
//
// Writes are excluded: the object being presigned for PUT does not exist yet.
var size int64
var sizeKnown bool
if operation != http.MethodPut {
var err error
size, sizeKnown, err = h.ociBlobSize(r.Context(), digest)
if errors.Is(err, errOCIBlobMissing) {
// Storage says the object is not there, so there is nothing to hand
// out a read capability for. Say so here rather than presigning a
// URL whose only possible answer is a 404 from S3.
slog.Debug("OCI blob not present in storage", "digest", digest, "operation", operation)
render.Status(r, http.StatusNotFound)
render.JSON(w, r, map[string]string{
"error": "BlobUnknown",
"message": "blob not found",
})
return
}
}
// Generate presigned URL (use empty DID for content-addressed storage)
presignedURL, err := h.GetPresignedURL(r.Context(), operation, digest, "")
if err != nil {
slog.Error("Failed to get presigned URL for OCI blob",
"error", err,
"operation", operation,
"digest", digest)
http.Error(w, "failed to get presigned URL", http.StatusInternalServerError)
return
}
slog.Debug("Returning presigned URL for OCI blob",
"operation", operation,
"digest", digest,
"url", presignedURL,
"size_known", sizeKnown)
// Return JSON response with presigned URL (AppView expects this format).
//
// "size" is additive: a client that only reads "url" behaves exactly as
// before, and it is omitted entirely when the size could not be resolved so
// that absent stays distinguishable from zero.
response := map[string]any{
"url": presignedURL,
}
if sizeKnown {
response["size"] = size
}
render.JSON(w, r, response)
}
// errOCIBlobMissing reports that storage was asked about an OCI blob and
// answered that it does not exist. It is distinct from a lookup that merely
// failed: a failed lookup leaves the size unknown, a missing object is a 404.
var errOCIBlobMissing = errors.New("oci blob not found in storage")
// ociBlobSize resolves the byte size of an OCI blob, cheapest source first.
//
// The records index knows the size of every layer that has been through a
// manifest notification, and answers from SQLite without touching the network.
// Anything else (an image config blob, which never gets a layer record, or a
// layer whose manifest notification has not landed yet) falls back to a
// HeadObject against S3.
//
// A lookup failure that is not a missing object leaves the size unknown rather
// than failing the request: the caller can still be handed a presigned URL, and
// an AppView that gets no size falls back to HEADing that URL as it always did.
func (h *XRPCHandler) ociBlobSize(ctx context.Context, digest string) (int64, bool, error) {
if idx := h.pds.RecordsIndex(); idx != nil {
size, ok, err := idx.SizeForDigest(digest)
if err != nil {
slog.Warn("Records index size lookup failed", "error", err, "digest", digest)
} else if ok {
return size, true, nil
}
}
if h.s3Service.Client == nil {
// No S3 client: GetPresignedURL falls back to the XRPC proxy, and there
// is nothing to ask about the object.
return 0, false, nil
}
size, err := h.s3Service.Stat(ctx, s3.BlobPath(digest))
if err != nil {
if s3.IsNotFound(err) {
return 0, false, errOCIBlobMissing
}
slog.Warn("Failed to stat OCI blob for size", "error", err, "digest", digest)
return 0, false, nil
}
return size, true, nil
}
// handleGetATProtoBlob handles standard ATProto blob requests
// Returns 307 redirect to presigned URL (standard ATProto behavior)
// Authorization:
// - GET/HEAD: public per ATProto spec (no auth required)
// - PUT: hold write access (captain, or crew with blob:write). The public read
// is a spec obligation and stops at reads; a presigned PUT would let any
// anonymous caller write to /repos/{did}/blobs/{cid}/data.
func (h *XRPCHandler) handleGetATProtoBlob(w http.ResponseWriter, r *http.Request, did, blobCID string) {
slog.Debug("Processing ATProto blob", "cid", blobCID)
// Validate DID (ATProto blobs are stored per-DID for data sovereignty)
if did != h.pds.DID() {
slog.Warn("ATProto blob DID mismatch",
"got", did,
"expected", h.pds.DID())
http.Error(w, "invalid did", http.StatusBadRequest)
return
}
// Validate the CID before it becomes an S3 key
if err := validateATProtoBlobCID(blobCID); err != nil {
slog.Warn("Rejected ATProto blob request with malformed cid", "error", err, "cid", blobCID)
http.Error(w, fmt.Sprintf("invalid cid: %v", err), http.StatusBadRequest)
return
}
// Determine presigned URL operation (GET, HEAD or PUT)
operation, err := parseBlobPresignOperation(r)
if err != nil {
slog.Warn("Rejected ATProto blob request with unsupported method", "error", err, "cid", blobCID)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if operation == http.MethodPut {
// Write capability: gate on the hold's write model. This endpoint is
// otherwise unauthenticated.
if _, err := ValidateBlobWriteAccess(r, h.pds, h.httpClient); err != nil {
slog.Warn("ATProto blob write authorization failed", "error", err, "cid", blobCID)
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
return
}
}
// Generate presigned URL (use DID for per-DID storage path)
presignedURL, err := h.GetPresignedURL(r.Context(), operation, blobCID, did)
if err != nil {
slog.Error("Failed to get presigned URL for ATProto blob",
"error", err,
"operation", operation,
"cid", blobCID,
"did", did)
http.Error(w, "failed to get presigned URL", http.StatusInternalServerError)
return
}
// Return 307 redirect (standard ATProto behavior - client fetches blob directly)
http.Redirect(w, r, presignedURL, http.StatusTemporaryRedirect)
}
// HandleListRepos lists all repositories in this PDS
func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) {
// Single-user PDS: return just this hold's repo
did := h.pds.DID()
// Get repo head and rev from repomgr
// For a single-user PDS, we use a fixed UID (stored in pds.uid)
head, err := h.pds.repomgr.GetRepoRoot(r.Context(), h.pds.uid)
if err != nil {
// If no repo exists yet, return empty list
render.JSON(w, r, map[string]any{"repos": []any{}})
return
}
rev, err := h.pds.repomgr.GetRepoRev(r.Context(), h.pds.uid)
if err != nil || rev == "" {
// No commits yet, return empty list
// Don't expose repos with no revision (empty/uninitialized)
render.JSON(w, r, map[string]any{"repos": []any{}})
return
}
repos := []map[string]any{
{
"did": did,
"head": head.String(),
"rev": rev,
"active": true,
},
}
render.JSON(w, r, map[string]any{
"repos": repos,
})
}
// HandleListBlobs lists blob CIDs for an account
// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-list-blobs
func (h *XRPCHandler) HandleListBlobs(w http.ResponseWriter, r *http.Request) {
did := r.URL.Query().Get("did")
if did == "" {
http.Error(w, "missing required parameter: did", http.StatusBadRequest)
return
}
if did != h.pds.DID() {
http.Error(w, "repo not found", http.StatusNotFound)
return
}
// List ATProto blobs from storage (S3 prefix listing)
safeDID := strings.ReplaceAll(did, ":", "-")
blobsPath := fmt.Sprintf("/repos/%s/blobs", safeDID)
entries, err := h.s3Service.ListPrefix(r.Context(), blobsPath)
if err != nil {
// Path doesn't exist = no blobs, return empty list
render.JSON(w, r, map[string]any{"cids": []string{}})
return
}
cids := make([]string, 0, len(entries))
for _, entry := range entries {
// entry is like "/repos/.../blobs/{cid}" — extract the CID
parts := strings.Split(entry, "/")
if len(parts) > 0 {
cids = append(cids, parts[len(parts)-1])
}
}
render.JSON(w, r, map[string]any{"cids": cids})
}
// HandleGetRepoStatus returns the hosting status for a repository
// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status
func (h *XRPCHandler) HandleGetRepoStatus(w http.ResponseWriter, r *http.Request) {
// Get required 'did' parameter
did := r.URL.Query().Get("did")
if did == "" {
http.Error(w, "missing required parameter: did", http.StatusBadRequest)
return
}
// Validate DID matches this PDS (single-user PDS only hosts one repo)
if did != h.pds.DID() {
http.Error(w, "repo not found", http.StatusNotFound)
return
}
// Get current repo revision to verify repo is initialized
rev, err := h.pds.repomgr.GetRepoRev(r.Context(), h.pds.uid)
if err != nil || rev == "" {
// Repo exists (DID matches) but no commits yet
// Per ATProto spec, return active=true even if empty
render.JSON(w, r, map[string]any{
"did": did,
"active": true,
})
return
}
// Return status with revision
render.JSON(w, r, map[string]any{
"did": did,
"active": true,
"rev": rev,
})
}
// HandleGetLatestCommit returns the current commit CID and revision for a repository.
// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-latest-commit
func (h *XRPCHandler) HandleGetLatestCommit(w http.ResponseWriter, r *http.Request) {
did := r.URL.Query().Get("did")
if did == "" {
http.Error(w, "missing required parameter: did", http.StatusBadRequest)
return
}
if did != h.pds.DID() {
http.Error(w, "RepoNotFound", http.StatusNotFound)
return
}
head, err := h.pds.repomgr.GetRepoRoot(r.Context(), h.pds.uid)
if err != nil {
http.Error(w, "RepoNotFound", http.StatusNotFound)
return
}
rev, err := h.pds.repomgr.GetRepoRev(r.Context(), h.pds.uid)
if err != nil || rev == "" {
http.Error(w, "RepoNotFound", http.StatusNotFound)
return
}
render.JSON(w, r, map[string]any{
"cid": head.String(),
"rev": rev,
})
}
// HandleDIDDocument returns the DID document
func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request) {
doc, err := did.BuildDIDDocument(h.pds.DID(), h.pds.PublicURL, h.pds.SigningKey(), "atproto", HoldServices(h.pds.PublicURL))
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
return
}
render.JSON(w, r, doc)
}
// HandleAtprotoDID returns the DID for handle resolution
func (h *XRPCHandler) HandleAtprotoDID(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, h.pds.DID())
}
// HandleRequestCrew handles crew membership requests
// This endpoint allows authenticated users to request crew membership
// Authorization is checked against captain record settings
func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request) {
slog.Debug("Starting crew membership request")
// Get authenticated user from context (if coming through middleware)
// Otherwise validate directly (for tests or direct handler calls)
user := getUserFromContext(r)
if user == nil {
var err error
user, err = ValidateDPoPRequest(r, h.httpClient)
if err != nil {
slog.Warn("Crew request authentication failed", "error", err)
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
return
}
}
slog.Debug("Authenticated user for crew request", "did", user.DID)
// Parse request body (optional parameters)
var req struct {
Role string `json:"role"` // Requested role (default: "member")
Permissions []string `json:"permissions"` // Requested permissions
}
// Body is optional - if empty, just use defaults
if r.Body != nil && r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
slog.Warn("Failed to parse crew request body", "error", err)
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
}
// Get captain record to check authorization settings
slog.Debug("Getting captain record for crew request")
_, captain, err := h.pds.GetCaptainRecord(r.Context())
if err != nil {
slog.Error("Failed to get captain record", "error", err)
http.Error(w, fmt.Sprintf("failed to get captain record: %v", err), http.StatusInternalServerError)
return
}
slog.Debug("Captain record retrieved",
"owner", captain.Owner,
"allowAllCrew", captain.AllowAllCrew)
// Check authorization:
// 1. If allowAllCrew is true, any authenticated user can join
// 2. If user is the owner, they can always join (though they should already be crew)
// 3. Otherwise, deny
isOwner := user.DID == captain.Owner
if !captain.AllowAllCrew && !isOwner {
http.Error(w, "crew registration not allowed (HOLD_ALLOW_ALL_CREW=false)", http.StatusForbidden)
return
}
// Set defaults if not provided
if req.Role == "" {
req.Role = "member"
}
if len(req.Permissions) == 0 {
req.Permissions = []string{"blob:read", "blob:write"}
}
// Check if user is already a crew member
// List all crew members and check if this DID is already present
slog.Debug("Checking existing crew membership")
crew, err := h.pds.ListCrewMembers(r.Context())
if err != nil {
slog.Error("Failed to list crew members", "error", err)
http.Error(w, fmt.Sprintf("failed to list crew members: %v", err), http.StatusInternalServerError)
return
}
slog.Debug("Found existing crew members", "count", len(crew))
for _, member := range crew {
if member.Record.Member == user.DID {
// Already a crew member, return success with existing record
slog.Debug("User is already a crew member",
"did", user.DID,
"rkey", member.Rkey)
render.JSON(w, r, map[string]any{
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.CrewCollection, member.Rkey),
"cid": member.Cid.String(),
"status": "already_member",
"message": "User is already a crew member",
})
return
}
}
// Create new crew record with default tier from quota config
defaultTier := ""
if h.quotaMgr != nil && h.quotaMgr.IsEnabled() {
defaultTier = h.quotaMgr.GetDefaultTier()
}
slog.Debug("Creating new crew record",
"did", user.DID,
"role", req.Role,
"permissions", req.Permissions,
"tier", defaultTier)
recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions, defaultTier)
if err != nil {
slog.Error("Failed to create crew record",
"error", err,
"did", user.DID)
http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError)
return
}
slog.Info("Successfully created crew record",
"did", user.DID,
"cid", recordCID.String())
// Return success response
// Note: rkey is generated by AddCrewMember (TID), we don't have direct access to it
// For now, return just the CID. In production, AddCrewMember should return both CID and rkey
render.Status(r, http.StatusCreated)
render.JSON(w, r, map[string]any{
"cid": recordCID.String(),
"status": "created",
"message": "Successfully added to crew",
})
}
// parseBlobPresignOperation resolves the ?method= query parameter into the S3
// operation to presign.
//
// The allowlist is the point. The parameter is caller-controlled and decides
// whether the endpoint hands back a read capability or a write one, so an
// unrecognised value is refused rather than defaulted: silently treating
// "put" or "PATCH" as a read hides a client bug, and any future operation
// added to GetPresignedURL is unreachable from here until it is named.
//
// GET and HEAD are reads and are served under the endpoint's read rules. PUT
// mints a write capability and MUST be gated by the caller on
// ValidateBlobWriteAccess before the URL is generated.
func parseBlobPresignOperation(r *http.Request) (string, error) {
switch operation := r.URL.Query().Get("method"); operation {
case "":
// No method parameter: this is a plain blob read.
return http.MethodGet, nil
case http.MethodGet, http.MethodHead, http.MethodPut:
return operation, nil
default:
return "", fmt.Errorf("unsupported method %q: expected GET, HEAD or PUT", operation)
}
}
// ociDigestHexLen is the number of hex characters in a sha256 digest.
const ociDigestHexLen = 64
// validateOCIDigest checks that an OCI blob digest is exactly "sha256:"
// followed by 64 lowercase hex characters.
//
// The digest arrives as a query parameter and is turned into an S3 key by
// s3.BlobPath, which shards on its first two characters and interpolates the
// rest. Constraining it to [0-9a-f] means it cannot carry a separator, a dot
// or a NUL, so it cannot name a key outside the blob space it belongs to.
// Only sha256 is accepted: it is the only algorithm ATCR stores, and the only
// one the sha256: prefix check in HandleGetBlob routes here in the first place.
func validateOCIDigest(digest string) error {
hex, ok := strings.CutPrefix(digest, "sha256:")
if !ok {
return fmt.Errorf("digest %q is not a sha256 digest", digest)
}
if len(hex) != ociDigestHexLen {
return fmt.Errorf("digest %q has %d hex characters, want %d", digest, len(hex), ociDigestHexLen)
}
for i := 0; i < len(hex); i++ {
if c := hex[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return fmt.Errorf("digest %q is not lowercase hex", digest)
}
}
return nil
}
// validateATProtoBlobCID checks that an ATProto blob CID is a well formed CID
// in its canonical string form.
//
// Same reasoning as validateOCIDigest: the value is caller-controlled and
// becomes a path element in atprotoBlobPath. Decoding is not sufficient on its
// own, so the parsed CID is re-encoded and compared: that rejects any trailing
// or alternative-encoding variant and guarantees the string written into the
// key is exactly the one the CID denotes.
func validateATProtoBlobCID(blobCID string) error {
parsed, err := cid.Decode(blobCID)
if err != nil {
return fmt.Errorf("%q is not a valid CID: %w", blobCID, err)
}
if parsed.String() != blobCID {
return fmt.Errorf("%q is not a CID in canonical form", blobCID)
}
return nil
}
// GetPresignedURL generates a presigned URL for GET, HEAD, or PUT operations
// Distinguishes between ATProto blobs (per-DID) and OCI blobs (content-addressed)
//
// PUT returns a write capability for the hold's bucket, valid for 15 minutes.
// Callers are responsible for authorizing the write (ValidateBlobWriteAccess)
// and for validating the digest or CID (validateOCIDigest,
// validateATProtoBlobCID) before calling with an operation of PUT.
func (h *XRPCHandler) GetPresignedURL(ctx context.Context, operation string, digest string, did string) (string, error) {
var path string
// Determine blob type and construct appropriate path
if strings.HasPrefix(digest, "sha256:") || strings.HasPrefix(digest, "uploads/") {
// OCI container layer (sha256 digest or temp upload path)
// Use content-addressed storage (globally deduplicated)
path = s3.BlobPath(digest)
} else {
// ATProto blob (CID format like bafyreib...)
// Use per-DID storage for data sovereignty
if did == "" {
return "", fmt.Errorf("DID required for ATProto blob storage")
}
path = atprotoBlobPath(did, digest)
}
// Generate presigned URL if S3 client is available
if h.s3Service.Client != nil {
// Build S3 key from blob path
s3Key := strings.TrimPrefix(path, "/")
if h.s3Service.PathPrefix != "" {
s3Key = h.s3Service.PathPrefix + "/" + s3Key
}
// Generate presigned URL with 15 minute expiry
var url string
var err error
contentType := "application/octet-stream"
switch operation {
case http.MethodGet:
// Note: Don't use ResponseContentType - not supported by all S3-compatible services
url, err = h.s3Service.Client.PresignGetObject(ctx, &awss3.GetObjectInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
}, 15*time.Minute)
case http.MethodHead:
url, err = h.s3Service.Client.PresignHeadObject(ctx, &awss3.HeadObjectInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
}, 15*time.Minute)
case http.MethodPut:
url, err = h.s3Service.Client.PresignPutObject(ctx, &awss3.PutObjectInput{
Bucket: &h.s3Service.Bucket,
Key: &s3Key,
ContentType: &contentType,
}, 15*time.Minute)
default:
return "", fmt.Errorf("unsupported operation: %s", operation)
}
if err != nil {
slog.Warn("Presign failed, falling back to XRPC endpoint",
"error", err,
"operation", operation,
"digest", digest)
slog.Debug("Using XRPC proxy fallback")
proxyURL := getProxyURL(h.pds.PublicURL, digest, h.pds.DID(), operation)
if proxyURL == "" {
return "", fmt.Errorf("presign failed and XRPC proxy not supported for PUT operations")
}
return proxyURL, nil
}
return url, nil
}
// Fallback: return XRPC endpoint through this service
proxyURL := getProxyURL(h.pds.PublicURL, digest, h.pds.DID(), operation)
if proxyURL == "" {
return "", fmt.Errorf("S3 client not available and XRPC proxy not supported for PUT operations")
}
return proxyURL, nil
}
// atprotoBlobPath creates a per-DID storage path for ATProto blobs
// ATProto spec stores blobs as: /repos/{did}/blobs/{cid}/data
// This provides data sovereignty - each user's blobs are isolated
func atprotoBlobPath(did, cid string) string {
// Clean DID for filesystem safety (replace : with -)
safeDID := strings.ReplaceAll(did, ":", "-")
return fmt.Sprintf("/repos/%s/blobs/%s/data", safeDID, cid)
}
// getProxyURL returns XRPC endpoint for blob operations (fallback when presigned URLs unavailable)
// For GET/HEAD operations, returns the XRPC getBlob endpoint
// For PUT operations, this fallback is no longer supported - use multipart upload instead
func getProxyURL(publicURL string, digest, holdDID string, operation string) string {
// For read operations, use XRPC getBlob endpoint
if operation == http.MethodGet || operation == http.MethodHead {
return fmt.Sprintf("%s%s?did=%s&cid=%s",
publicURL, atproto.SyncGetBlob, holdDID, digest)
}
// Anything else, PUT included, gets no fallback URL. getBlob is a read
// endpoint and its proxy target would be reached without the write gate
// that guards the presign path, so there is no safe write fallback to
// return here. Clients should use the multipart upload flow, or
// com.atproto.repo.uploadBlob for small ATProto blobs; both authorize the
// write themselves.
return ""
}
// HandleGetQuota returns storage quota information for a user
// This calculates the total unique blob storage used by a specific user
// by iterating layer records and deduplicating by digest.
// Also returns tier-aware quota limits if quotas.yaml is configured.
func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) {
userDID := r.URL.Query().Get("userDid")
if userDID == "" {
http.Error(w, "missing required parameter: userDid", http.StatusBadRequest)
return
}
// Validate DID format
if _, err := syntax.ParseDID(userDID); err != nil {
http.Error(w, "invalid userDid format", http.StatusBadRequest)
return
}
// Get quota stats with tier-aware limits
stats, err := h.pds.GetQuotaForUserWithTier(r.Context(), userDID, h.quotaMgr)
if err != nil {
slog.Error("Failed to get quota", "userDid", userDID, "error", err)
http.Error(w, fmt.Sprintf("failed to get quota: %v", err), http.StatusInternalServerError)
return
}
render.JSON(w, r, stats)
}
// HandleListTiers returns the hold's available tiers with storage quotas.
// This is a public endpoint (no auth) so the appview UI can display "3-10 GB depending on region."
func (h *XRPCHandler) HandleListTiers(w http.ResponseWriter, r *http.Request) {
if !h.quotaMgr.IsEnabled() {
render.JSON(w, r, map[string]any{"tiers": []any{}})
return
}
tierInfos := h.quotaMgr.ListTiers()
tiers := make([]map[string]any, 0, len(tierInfos))
for _, t := range tierInfos {
var quotaBytes int64
if t.Limit != nil {
quotaBytes = *t.Limit
}
tiers = append(tiers, map[string]any{
"name": t.Key,
"quotaBytes": quotaBytes,
"quotaFormatted": quota.FormatHumanBytes(quotaBytes),
"scanOnPush": t.ScanOnPush,
})
}
render.JSON(w, r, map[string]any{"tiers": tiers})
}
// HandleUpdateCrewTier updates a crew member's tier. Only accepts requests from the trusted appview.
// Auth: Bearer token signed by the appview's P-256 key (ES256 JWT).
func (h *XRPCHandler) HandleUpdateCrewTier(w http.ResponseWriter, r *http.Request) {
if h.appviewDID == "" {
http.Error(w, "appview DID not configured on this hold", http.StatusServiceUnavailable)
return
}
// Validate appview token
userDID, err := ValidateAppviewToken(r, h.appviewDID, h.pds.DID())
if err != nil {
slog.Warn("Appview token validation failed for updateCrewTier", "error", err)
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
return
}
// Parse request body
var req struct {
UserDID string `json:"userDid"`
TierRank int `json:"tierRank"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// The signature-validated sub claim is the source of truth for which user
// this tier applies to. If the body also carries a userDid, it must match;
// reject a mismatch rather than letting the unauthenticated body retarget
// the grant to a different DID.
if req.UserDID != "" && req.UserDID != userDID {
slog.Warn("updateCrewTier body userDid does not match token sub",
"bodyUserDid", req.UserDID, "tokenSub", userDID)
http.Error(w, "userDid does not match authenticated subject", http.StatusForbidden)
return
}
if _, err := syntax.ParseDID(userDID); err != nil {
http.Error(w, "invalid userDid format", http.StatusBadRequest)
return
}
// Map tier rank to tier name
tierName := h.resolveTierByRank(req.TierRank)
if tierName == "" {
http.Error(w, "no tiers configured on this hold", http.StatusBadRequest)
return
}
// A tier only applies to crew members. The appview fans a tier update out to
// ALL managed holds, but a user is only crew on holds they've actually pushed
// to (and a brand-new subscriber may be crew nowhere yet). Treat "not crew on
// this hold" as a successful no-op rather than an error — otherwise the Stripe
// webhook would 500 and retry forever on holds where there's nothing to do.
// Only a genuinely missing record counts as "not crew"; see below.
if _, _, err := h.pds.GetCrewMemberByDID(r.Context(), userDID); err != nil {
if !errors.Is(err, ErrCrewMemberNotFound) {
// A storage failure is not the same as "not a member". Answering 200
// here would let the appview mark the Stripe event processed and drop
// the tier grant for good, so fail loudly and let Stripe redeliver.
slog.Error("Failed to look up crew membership for tier update",
"userDid", userDID, "tierName", tierName, "error", err)
http.Error(w, "failed to look up crew membership", http.StatusInternalServerError)
return
}
slog.Info("Skipping tier update; user is not crew on this hold", "userDid", userDID, "tierName", tierName)
render.JSON(w, r, map[string]any{"tierName": tierName, "applied": false})
return
}
// Update the crew member's tier
if err := h.pds.UpdateCrewMemberTier(r.Context(), userDID, tierName); err != nil {
slog.Error("Failed to update crew tier", "userDid", userDID, "tier", tierName, "error", err)
http.Error(w, fmt.Sprintf("failed to update tier: %v", err), http.StatusInternalServerError)
return
}
slog.Info("Updated crew tier via appview", "userDid", userDID, "tierRank", req.TierRank, "tierName", tierName)
render.JSON(w, r, map[string]any{"tierName": tierName, "applied": true})
}
// resolveTierByRank maps a 0-based rank index to a tier name from the quota config.
// If the rank exceeds the number of tiers, it clamps to the highest tier.
func (h *XRPCHandler) resolveTierByRank(rank int) string {
if !h.quotaMgr.IsEnabled() {
return ""
}
tiers := h.quotaMgr.ListTiers()
if len(tiers) == 0 {
return ""
}
if rank < 0 {
rank = 0
}
if rank >= len(tiers) {
rank = len(tiers) - 1
}
return tiers[rank].Key
}
// 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"`
BlueskyPosts []BlueskyPostExport `json:"bluesky_posts"`
}
// 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"`
}
// 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.
//
// 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
// - 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
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{},
BlueskyPosts: []BlueskyPostExport{},
}
// 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,
})
}
}
// 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),
"post_count", len(export.BlueskyPosts))
render.JSON(w, r, export)
}
// HoldUserDeleteResponse represents the result of GDPR data deletion
type HoldUserDeleteResponse struct {
Success bool `json:"success"`
CrewDeleted bool `json:"crew_deleted"`
LayersDeleted int `json:"layers_deleted"`
StatsDeleted int `json:"stats_deleted"`
}
// HandleDeleteUserData handles GDPR data deletion requests for a specific user.
// This endpoint deletes all records stored on this hold's PDS that reference
// the authenticated user's DID.
//
// Deletes:
// - io.atcr.hold.crew record for the DID (if exists, and user is NOT captain)
// - io.atcr.hold.layer records where userDid matches
// - io.atcr.hold.stats records where ownerDid matches
//
// 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.
//
// Authentication: Requires valid service token from user's PDS
func (h *XRPCHandler) HandleDeleteUserData(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 deletion requested",
"requester_did", user.DID,
"hold_did", h.pds.DID())
// Check if user is captain - if so, skip crew deletion but continue with layer/stats
isCaptain := false
_, captain, err := h.pds.GetCaptainRecord(r.Context())
if err == nil && captain != nil && captain.Owner == user.DID {
isCaptain = true
slog.Info("User is captain of this hold, will not delete captain record",
"user_did", user.DID,
"hold_did", h.pds.DID())
}
// Delete user data from hold
result, err := h.pds.DeleteUserData(r.Context(), user.DID)
if err != nil {
slog.Error("Failed to delete user data",
"user_did", user.DID,
"hold_did", h.pds.DID(),
"error", err)
http.Error(w, fmt.Sprintf("failed to delete user data: %v", err), http.StatusInternalServerError)
return
}
// If user is captain, they shouldn't have a crew record deleted (they're the owner)
// The DeleteUserData function handles crew deletion, but we report it appropriately
if isCaptain {
result.CrewDeleted = false
}
slog.Info("GDPR data deletion completed",
"user_did", user.DID,
"hold_did", h.pds.DID(),
"crew_deleted", result.CrewDeleted,
"layers_deleted", result.LayersDeleted,
"stats_deleted", result.StatsDeleted)
render.JSON(w, r, HoldUserDeleteResponse{
Success: true,
CrewDeleted: result.CrewDeleted,
LayersDeleted: result.LayersDeleted,
StatsDeleted: result.StatsDeleted,
})
}
// didWebHandle extracts the hostname (handle) from a did:web DID,
// decoding percent-encoded ports (e.g. did:web:host%3A8080 → host:8080).
func didWebHandle(did string) string {
host := strings.TrimPrefix(did, "did:web:")
return strings.ReplaceAll(host, "%3A", ":")
}