mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-26 20:24:16 +00:00
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
2308 lines
76 KiB
Go
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", ":")
|
|
}
|