Files
at-container-registry/pkg/hold/pds/xrpc.go
T

1155 lines
37 KiB
Go

package pds
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"atcr.io/pkg/atproto"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/bluesky-social/indigo/repo"
"github.com/gorilla/websocket"
"github.com/ipfs/go-cid"
"github.com/ipld/go-car"
carutil "github.com/ipld/go-car/util"
)
// XRPC handler for ATProto endpoints
// XRPCHandler handles XRPC requests for the embedded PDS
type XRPCHandler struct {
pds *HoldPDS
publicURL string
blobStore BlobStore
broadcaster *EventBroadcaster
httpClient HTTPClient // For testing - allows injecting mock HTTP client
}
// BlobStore interface wraps the existing hold service storage operations
type BlobStore interface {
// GetPresignedDownloadURL returns a presigned URL for downloading a blob
// For ATProto blobs (CID), did is required for per-DID storage
// For OCI blobs (sha256:...), did may be empty
GetPresignedDownloadURL(digest, did string) (string, error)
// GetPresignedUploadURL returns a presigned URL for uploading a blob
// For ATProto blobs (CID), did is required for per-DID storage
// For OCI blobs (sha256:...), did may be empty
GetPresignedUploadURL(digest, did string) (string, error)
// UploadBlob receives raw blob bytes, computes CID, and stores via distribution driver
// Used for standard ATProto blob uploads (profile pics, small media)
// Returns CID and size of stored blob
UploadBlob(ctx context.Context, did string, data io.Reader) (cid cid.Cid, size int64, err error)
// Multipart upload operations (used for OCI container layers only)
// StartMultipartUpload initiates a multipart upload, returns uploadID and mode
StartMultipartUpload(ctx context.Context, digest string) (uploadID string, mode string, err error)
// GetPartUploadURL returns structured upload info (URL + optional headers) for a specific part
GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (*PartUploadInfo, error)
// CompleteMultipartUpload finalizes a multipart upload
CompleteMultipartUpload(ctx context.Context, uploadID string, parts []PartInfo) error
// AbortMultipartUpload cancels a multipart upload
AbortMultipartUpload(ctx context.Context, uploadID string) error
// HandleBufferedPartUpload handles uploading a part in buffered mode
HandleBufferedPartUpload(ctx context.Context, uploadID string, partNumber int, data []byte) (etag string, err error)
}
// 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, publicURL string, blobStore BlobStore, broadcaster *EventBroadcaster, httpClient HTTPClient) *XRPCHandler {
return &XRPCHandler{
pds: pds,
publicURL: publicURL,
blobStore: blobStore,
broadcaster: broadcaster,
httpClient: httpClient,
}
}
// corsMiddleware wraps a handler with CORS headers
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, DPoP, X-Upload-Id, X-Part-Number, X-ATCR-DID")
// Handle preflight OPTIONS requests
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
// RegisterHandlers registers all XRPC endpoints
func (h *XRPCHandler) RegisterHandlers(mux *http.ServeMux) {
// Health check endpoint
mux.HandleFunc("/xrpc/_health", corsMiddleware(h.HandleHealth))
// Standard PDS endpoints
mux.HandleFunc("/xrpc/com.atproto.server.describeServer", corsMiddleware(h.HandleDescribeServer))
mux.HandleFunc("/xrpc/com.atproto.repo.describeRepo", corsMiddleware(h.HandleDescribeRepo))
mux.HandleFunc("/xrpc/com.atproto.repo.getRecord", corsMiddleware(h.HandleGetRecord))
mux.HandleFunc("/xrpc/com.atproto.repo.listRecords", corsMiddleware(h.HandleListRecords))
// Sync endpoints
mux.HandleFunc("/xrpc/com.atproto.sync.listRepos", corsMiddleware(h.HandleListRepos))
mux.HandleFunc("/xrpc/com.atproto.sync.getRecord", corsMiddleware(h.HandleSyncGetRecord))
mux.HandleFunc("/xrpc/com.atproto.sync.getRepo", corsMiddleware(h.HandleGetRepo))
mux.HandleFunc("/xrpc/com.atproto.sync.subscribeRepos", corsMiddleware(h.HandleSubscribeRepos))
// Blob endpoints (wrap existing presigned URL logic)
mux.HandleFunc("/xrpc/com.atproto.repo.uploadBlob", corsMiddleware(h.HandleUploadBlob))
mux.HandleFunc("/xrpc/com.atproto.sync.getBlob", corsMiddleware(h.HandleGetBlob))
// DID document and handle resolution
mux.HandleFunc("/.well-known/did.json", corsMiddleware(h.HandleDIDDocument))
mux.HandleFunc("/.well-known/atproto-did", corsMiddleware(h.HandleAtprotoDID))
// Write endpoints
mux.HandleFunc("/xrpc/com.atproto.repo.deleteRecord", corsMiddleware(h.HandleDeleteRecord))
// Custom ATCR endpoints
mux.HandleFunc("/xrpc/io.atcr.hold.requestCrew", corsMiddleware(h.HandleRequestCrew))
}
// HandleHealth returns health check information
func (h *XRPCHandler) HandleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]any{
"version": "0.4.999",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleDescribeServer returns server metadata
func (h *XRPCHandler) HandleDescribeServer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract hostname from public URL for availableUserDomains
// For hold01.atcr.io, return [".hold01.atcr.io"] to match stream.place pattern
hostname := h.publicURL
hostname = strings.TrimPrefix(hostname, "http://")
hostname = strings.TrimPrefix(hostname, "https://")
hostname = strings.Split(hostname, "/")[0] // Remove path
hostname = strings.Split(hostname, ":")[0] // Remove port
response := map[string]any{
"did": h.pds.DID(),
"availableUserDomains": []string{"." + hostname},
"inviteCodeRequired": true, // Single-user PDS, no account creation
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleDescribeRepo returns repository information
func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// 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 := h.pds.GenerateDIDDocument(h.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)
response := map[string]any{
"did": h.pds.DID(),
"handle": h.pds.DID(),
"didDoc": didDoc,
"collections": collections,
"handleIsCorrect": true,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleGetRecord retrieves a record from the repository
func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
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
}
response := map[string]any{
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey),
"cid": recordCID.String(),
"value": recordValue,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// 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
func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
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"
// 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
response := map[string]any{"records": []any{}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
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
}
// Initialize as empty slice (not nil) to ensure JSON encodes as [] not null
records := []map[string]any{}
var nextCursor string
skipUntilCursor := cursor != ""
// Iterate over all records in the collection
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
// MST keys are sorted lexicographically, so once we hit a different
// collection prefix, all remaining keys will also be outside our range
if actualCollection != collection {
return repo.ErrDoneIterating // Stop walking the tree
}
// Handle cursor-based pagination
if skipUntilCursor {
if rkey == cursor {
skipUntilCursor = false // Found cursor, start including records after this
}
return nil // Skip this record
}
// Check if we've hit the limit
if len(records) >= limit {
// Set next cursor to current rkey
nextCursor = rkey
return repo.ErrDoneIterating // Stop iteration
}
// Get the record bytes
recordCID, recBytes, err := repoHandle.GetRecordBytes(r.Context(), k)
if err != nil {
return fmt.Errorf("failed to get record: %w", err)
}
// Decode using lexutil (type registry handles unmarshaling)
recordValue, err := lexutil.CborDecodeValue(*recBytes)
if err != nil {
return fmt.Errorf("failed to decode record: %w", err)
}
records = append(records, map[string]any{
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), actualCollection, rkey),
"cid": recordCID.String(),
"value": recordValue,
})
return nil
})
if err != nil {
// ErrDoneIterating is expected when we stop walking early (reached collection boundary or hit limit)
// Check using strings.Contains because the error may be wrapped
if err == repo.ErrDoneIterating || strings.Contains(err.Error(), "done iterating") {
// Successfully stopped at collection boundary or hit pagination limit, continue with collected records
} else if strings.Contains(err.Error(), "not found") {
// If the collection doesn't exist yet, return empty list
records = []map[string]any{}
} else {
http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
return
}
}
// Handle reverse order if requested
if reverse && len(records) > 0 {
// Reverse the slice
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
records[i], records[j] = records[j], records[i]
}
}
response := map[string]any{
"records": records,
}
// Include cursor in response if there are more records
if nextCursor != "" {
response["cursor"] = nextCursor
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(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) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// 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
}
// Validate DPoP + OAuth and check authorization
_, err := ValidateOwnerOrCrewAdmin(r, h.pds, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("unauthorized: %v", err), http.StatusForbidden)
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
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(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)
response := map[string]any{
"commit": map[string]any{
"cid": head.String(),
"rev": rev,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleSyncGetRecord returns a single record as a CAR file for sync
func (h *XRPCHandler) HandleSyncGetRecord(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
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
}
// Support both captain and crew collections
if collection != atproto.CaptainCollection && collection != atproto.CrewCollection {
http.Error(w, "collection not found", http.StatusNotFound)
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
w.Write(buf.Bytes())
}
// 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) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// 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
fmt.Printf("Error streaming repo CAR: %v\n", 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) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Check if broadcaster is configured
if h.broadcaster == nil {
http.Error(w, "firehose not enabled", http.StatusNotImplemented)
return
}
// Get optional cursor parameter for backfill
var cursor int64 = 0
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 {
fmt.Printf("WebSocket upgrade failed: %v\n", err)
return
}
// Subscribe to events
sub := h.broadcaster.Subscribe(conn, cursor)
// The broadcaster's handleSubscriber goroutine will manage this connection
// We just need to keep reading to detect client disconnects
go func() {
defer h.broadcaster.Unsubscribe(sub)
for {
// Read messages from client (mostly just to detect disconnect)
_, _, err := conn.ReadMessage()
if err != nil {
// Client disconnected
break
}
}
}()
}
// HandleUploadBlob handles blob uploads with support for multipart operations
// Supports three modes:
// 1. Buffered part upload: PUT with X-Upload-Id and X-Part-Number headers
// 2. Multipart operations: POST with JSON body containing action field
// 3. Direct blob upload: POST with raw bytes (ATProto-compliant)
func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
// Mode 1: Buffered part upload (PUT with headers)
if r.Method == http.MethodPut {
uploadID := r.Header.Get("X-Upload-Id")
partNumberStr := r.Header.Get("X-Part-Number")
if uploadID != "" && partNumberStr != "" {
h.handleBufferedPartUpload(w, r, uploadID, partNumberStr)
return
}
http.Error(w, "PUT requires X-Upload-Id and X-Part-Number headers", http.StatusBadRequest)
return
}
// Ensure POST method for remaining modes
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Mode 2: Multipart operations (JSON body with action field)
if strings.Contains(contentType, "application/json") {
h.handleMultipartOperation(w, r)
return
}
// Mode 3: Direct blob upload (ATProto-compliant)
// Receives raw bytes, computes CID, stores via distribution driver
// Requires admin-level access (captain or crew admin)
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
// Upload blob directly - blobStore will compute CID and store
blobCID, size, err := h.blobStore.UploadBlob(r.Context(), did, r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to upload blob: %v", err), http.StatusInternalServerError)
return
}
// Return ATProto-compliant blob response
response := map[string]any{
"blob": map[string]any{
"$type": "blob",
"ref": map[string]any{
"$link": blobCID.String(),
},
"mimeType": "application/octet-stream",
"size": size,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleBufferedPartUpload handles uploading a part in buffered mode
func (h *XRPCHandler) handleBufferedPartUpload(w http.ResponseWriter, r *http.Request, uploadID, partNumberStr string) {
ctx := r.Context()
// Validate blob write access
// This checks DPoP + OAuth tokens and verifies user is captain or crew with blob:write permission
_, err := ValidateBlobWriteAccess(r, h.pds, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
return
}
// Parse part number
partNumber, err := strconv.Atoi(partNumberStr)
if err != nil {
http.Error(w, fmt.Sprintf("invalid part number: %v", err), http.StatusBadRequest)
return
}
// Read part data from body
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to read part data: %v", err), http.StatusInternalServerError)
return
}
// Store part via blob store
etag, err := h.blobStore.HandleBufferedPartUpload(ctx, uploadID, partNumber, data)
if err != nil {
http.Error(w, fmt.Sprintf("failed to upload part: %v", err), http.StatusInternalServerError)
return
}
// Return ETag in response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"etag": etag,
})
}
// handleMultipartOperation handles multipart upload operations via JSON request
func (h *XRPCHandler) handleMultipartOperation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse JSON body
var req struct {
Action string `json:"action"`
Digest string `json:"digest,omitempty"`
UploadID string `json:"uploadId,omitempty"`
PartNumber int `json:"partNumber,omitempty"`
Parts []PartInfo `json:"parts,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON body: %v", err), http.StatusBadRequest)
return
}
// Validate blob write access for all multipart operations
// This checks DPoP + OAuth tokens and verifies user is captain or crew with blob:write permission
user, err := ValidateBlobWriteAccess(r, h.pds, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
return
}
// Route based on action
switch req.Action {
case "start":
// Start multipart upload
if req.Digest == "" {
http.Error(w, "digest required for start action", http.StatusBadRequest)
return
}
uploadID, mode, err := h.blobStore.StartMultipartUpload(ctx, req.Digest)
if err != nil {
http.Error(w, fmt.Sprintf("failed to start multipart upload: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"uploadId": uploadID,
"mode": mode,
})
case "part":
// Get part upload URL
if req.UploadID == "" || req.PartNumber == 0 {
http.Error(w, "uploadId and partNumber required for part action", http.StatusBadRequest)
return
}
uploadInfo, err := h.blobStore.GetPartUploadURL(ctx, req.UploadID, req.PartNumber, user.DID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get part URL: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(uploadInfo)
case "complete":
// Complete multipart upload
if req.UploadID == "" || len(req.Parts) == 0 {
http.Error(w, "uploadId and parts required for complete action", http.StatusBadRequest)
return
}
if err := h.blobStore.CompleteMultipartUpload(ctx, req.UploadID, req.Parts); err != nil {
http.Error(w, fmt.Sprintf("failed to complete multipart upload: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "completed",
})
case "abort":
// Abort multipart upload
if req.UploadID == "" {
http.Error(w, "uploadId required for abort action", http.StatusBadRequest)
return
}
if err := h.blobStore.AbortMultipartUpload(ctx, req.UploadID); err != nil {
http.Error(w, fmt.Sprintf("failed to abort multipart upload: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "aborted",
})
default:
http.Error(w, fmt.Sprintf("unknown action: %s", req.Action), http.StatusBadRequest)
}
}
// HandleGetBlob wraps existing presigned download URL logic
// Supports both ATProto CIDs and OCI sha256 digests
// Authorization: If captain.public = true, open to all. If false, requires crew with blob:read permission.
func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
did := r.URL.Query().Get("did")
cidOrDigest := r.URL.Query().Get("cid")
if did == "" || cidOrDigest == "" {
http.Error(w, "missing required parameters", http.StatusBadRequest)
return
}
if did != h.pds.DID() {
http.Error(w, "invalid did", http.StatusBadRequest)
return
}
// Validate blob read access
// If captain.public = true, returns nil (public access allowed)
// If captain.public = false, validates auth and checks for blob:read permission
_, err := ValidateBlobReadAccess(r, h.pds, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
return
}
// Flexible digest parsing: accept both CID and sha256 digest formats
var digest string
if strings.HasPrefix(cidOrDigest, "sha256:") {
// OCI digest format - use directly
digest = cidOrDigest
} else {
// Standard ATProto CID - for ATCR OCI use case, we expect sha256 digests
// If a real CID is provided, we could convert it here, but for now
// we'll just pass it through and let the blob store handle it
digest = cidOrDigest
}
// Get presigned download URL from existing blob store
// Pass DID for ATProto blob storage (per-DID paths)
downloadURL, err := h.blobStore.GetPresignedDownloadURL(digest, did)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get download URL: %v", err), http.StatusInternalServerError)
return
}
// Return 302 redirect to presigned URL
http.Redirect(w, r, downloadURL, http.StatusTemporaryRedirect)
}
// HandleListRepos lists all repositories in this PDS
func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// 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
response := map[string]any{
"repos": []any{},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
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)
response := map[string]any{
"repos": []any{},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
repos := []map[string]any{
{
"did": did,
"head": head.String(),
"rev": rev,
"active": true,
},
}
response := map[string]any{
"repos": repos,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleDIDDocument returns the DID document
func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
doc, err := h.pds.GenerateDIDDocument(h.publicURL)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(doc)
}
// HandleAtprotoDID returns the DID for handle resolution
func (h *XRPCHandler) HandleAtprotoDID(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
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) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate DPoP + OAuth token from Authorization and DPoP headers
user, err := ValidateDPoPRequest(r, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
return
}
// 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 {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
}
// Get captain record to check authorization settings
_, captain, err := h.pds.GetCaptainRecord(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to get captain record: %v", err), http.StatusInternalServerError)
return
}
// 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
crew, err := h.pds.ListCrewMembers(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to list crew members: %v", err), http.StatusInternalServerError)
return
}
for _, member := range crew {
if member.Record.Member == user.DID {
// Already a crew member, return success with existing record
response := 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",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
return
}
}
// Create new crew record
recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError)
return
}
// 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
response := map[string]any{
"cid": recordCID.String(),
"status": "created",
"message": "Successfully added to crew",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}