lots of unit testing for xrpc endpoints. start pointing appview to the new endpoints. remove legacy api endpoints

This commit is contained in:
Evan Jarrett
2025-10-17 15:41:20 -05:00
parent 50d5eea4a5
commit 48414be75d
25 changed files with 1842 additions and 853 deletions
-8
View File
@@ -368,14 +368,6 @@ Write access:
Key insight: "Private" gates anonymous access, not authenticated access. This reflects ATProto's current limitation (no private PDS records yet).
**Endpoints:**
- `POST /get-presigned-url` - Get download URL for blob
- `POST /put-presigned-url` - Get upload URL for blob
- `GET /blobs/{digest}` - Proxy download (fallback if no presigned URL support)
- `PUT /blobs/{digest}` - Proxy upload (fallback)
- `POST /register` - Manual registration endpoint
- `GET /health` - Health check
**Embedded PDS Endpoints:**
Each hold service includes an embedded PDS (Personal Data Server) that stores captain + crew records:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25.2-trixie AS builder
FROM docker.io/golang:1.25.2-trixie AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25.2-trixie AS builder
FROM docker.io/golang:1.25.2-trixie AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \
+1 -51
View File
@@ -5,8 +5,6 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"atcr.io/pkg/hold"
"atcr.io/pkg/hold/pds"
@@ -66,7 +64,7 @@ func main() {
if holdPDS != nil {
holdDID := holdPDS.DID()
blobStore := hold.NewHoldServiceBlobStore(service, holdDID)
xrpcHandler = pds.NewXRPCHandler(holdPDS, cfg.Server.PublicURL, blobStore, broadcaster)
xrpcHandler = pds.NewXRPCHandler(holdPDS, cfg.Server.PublicURL, blobStore, broadcaster, nil)
}
// Setup HTTP routes
@@ -82,54 +80,6 @@ func main() {
http.NotFound(w, r)
})
mux.HandleFunc("/presigned-url", service.HandlePresignedURL)
mux.HandleFunc("/move", service.HandleMove)
// Multipart upload endpoints
mux.HandleFunc("/start-multipart", service.HandleStartMultipart)
mux.HandleFunc("/part-presigned-url", service.HandleGetPartURL)
mux.HandleFunc("/complete-multipart", service.HandleCompleteMultipart)
mux.HandleFunc("/abort-multipart", service.HandleAbortMultipart)
// Buffered multipart part upload endpoint (for when presigned URLs are disabled/unavailable)
mux.HandleFunc("/multipart-parts/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse URL: /multipart-parts/{uploadID}/{partNumber}
path := r.URL.Path[len("/multipart-parts/"):]
parts := strings.Split(path, "/")
if len(parts) != 2 {
http.Error(w, "invalid path format, expected /multipart-parts/{uploadID}/{partNumber}", http.StatusBadRequest)
return
}
uploadID := parts[0]
partNumber, err := strconv.Atoi(parts[1])
if err != nil {
http.Error(w, fmt.Sprintf("invalid part number: %v", err), http.StatusBadRequest)
return
}
// Get DID from query param
did := r.URL.Query().Get("did")
service.HandleMultipartPartUpload(w, r, uploadID, partNumber, did, service.MultipartMgr)
})
mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead:
service.HandleProxyGet(w, r)
case http.MethodPut:
service.HandleProxyPut(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
// Register XRPC/ATProto PDS endpoints if PDS is initialized
if xrpcHandler != nil {
log.Printf("Registering ATProto PDS endpoints")
+1 -1
View File
@@ -60,7 +60,7 @@ type NamespaceResolver struct {
distribution.Namespace
directory identity.Directory
defaultStorageEndpoint string
testMode bool // If true, fallback to default hold when user's hold is unreachable
testMode bool // If true, fallback to default hold when user's hold is unreachable
repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
}
+111 -146
View File
@@ -202,9 +202,10 @@ func (p *ProxyBlobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadS
}, nil
}
// Put stores a blob
// Put stores a blob using the multipart upload flow
// This ensures all uploads go through the same XRPC path
func (p *ProxyBlobStore) Put(ctx context.Context, mediaType string, content []byte) (distribution.Descriptor, error) {
// Check write access
// Check write access (fast-fail before starting multipart upload)
if err := p.checkWriteAccess(ctx); err != nil {
return distribution.Descriptor{}, err
}
@@ -212,41 +213,33 @@ func (p *ProxyBlobStore) Put(ctx context.Context, mediaType string, content []by
// Calculate digest
dgst := digest.FromBytes(content)
// Get upload URL
url, err := p.getUploadURL(ctx, dgst, int64(len(content)))
// Use Create() flow for all uploads (goes through multipart XRPC endpoints)
writer, err := p.Create(ctx)
if err != nil {
fmt.Printf("[proxy_blob_store/Put] Failed to get upload URL: digest=%s, error=%v\n", dgst, err)
fmt.Printf("[proxy_blob_store/Put] Failed to create writer: %v\n", err)
return distribution.Descriptor{}, err
}
// Upload the blob
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(content))
if err != nil {
fmt.Printf("[proxy_blob_store/Put] Failed to create request: %v\n", err)
// Write the content
if _, err := writer.Write(content); err != nil {
writer.Cancel(ctx)
fmt.Printf("[proxy_blob_store/Put] Failed to write content: %v\n", err)
return distribution.Descriptor{}, err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := p.httpClient.Do(req)
if err != nil {
fmt.Printf("[proxy_blob_store/Put] HTTP request failed: %v\n", err)
return distribution.Descriptor{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
fmt.Printf(" Error Body: %s\n", string(bodyBytes))
return distribution.Descriptor{}, fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
fmt.Printf("[proxy_blob_store/Put] Upload successful: digest=%s, size=%d\n", dgst, len(content))
return distribution.Descriptor{
// Commit with the calculated digest
desc, err := writer.Commit(ctx, distribution.Descriptor{
Digest: dgst,
Size: int64(len(content)),
MediaType: mediaType,
}, nil
})
if err != nil {
fmt.Printf("[proxy_blob_store/Put] Failed to commit: %v\n", err)
return distribution.Descriptor{}, err
}
fmt.Printf("[proxy_blob_store/Put] Upload successful: digest=%s, size=%d\n", dgst, len(content))
return desc, nil
}
// Delete removes a blob
@@ -348,75 +341,35 @@ func (p *ProxyBlobStore) Resume(ctx context.Context, id string) (distribution.Bl
return writer, nil
}
// getPresignedURL requests a presigned URL from the storage service for any operation
func (p *ProxyBlobStore) getPresignedURL(ctx context.Context, operation, dgst string, size int64) (string, error) {
reqBody := map[string]any{
"operation": operation,
"did": p.did,
"digest": dgst,
}
// Only include size for PUT operations
if size > 0 {
reqBody["size"] = size
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/presigned-url", p.storageEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get presigned URL: status %d", resp.StatusCode)
}
var result struct {
URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.URL, nil
}
// getDownloadURL requests a presigned download URL from the storage service
// getDownloadURL returns the XRPC getBlob URL for downloading a blob
// The hold service will redirect to a presigned S3 URL
func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) {
return p.getPresignedURL(ctx, "GET", dgst.String(), 0)
// Use XRPC endpoint: GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid={digest}
// Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix)
url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
p.storageEndpoint, p.holdDID, dgst.String())
return url, nil
}
// getHeadURL requests a presigned HEAD URL from the storage service
// getHeadURL returns the XRPC getBlob URL for HEAD requests
// The hold service will redirect to a presigned S3 URL
func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) {
return p.getPresignedURL(ctx, "HEAD", dgst.String(), 0)
// Same as GET - hold service handles HEAD method on getBlob endpoint
url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
p.storageEndpoint, p.holdDID, dgst.String())
return url, nil
}
// getUploadURL requests a presigned upload URL from the storage service
// getUploadURL is deprecated - single blob uploads should use Create() instead
// XRPC migration: No direct presigned upload URL endpoint, use multipart flow for all uploads
func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, size int64) (string, error) {
fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: storageEndpoint=%s, digest=%s\n", p.storageEndpoint, dgst)
url, err := p.getPresignedURL(ctx, "PUT", dgst.String(), size)
if err == nil {
fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: Got presigned URL=%s\n", url)
}
return url, err
return "", fmt.Errorf("single blob upload via Put() not supported with XRPC endpoints - use Create() instead")
}
// startMultipartUpload initiates a multipart upload via hold service
// startMultipartUpload initiates a multipart upload via XRPC uploadBlob endpoint
func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string) (string, error) {
reqBody := map[string]any{
"did": p.did,
"action": "start",
"digest": digest,
}
@@ -425,7 +378,7 @@ func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string
return "", err
}
url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint)
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", err
@@ -444,7 +397,8 @@ func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string
}
var result struct {
UploadID string `json:"upload_id"`
UploadID string `json:"uploadId"`
Mode string `json:"mode"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
@@ -453,55 +407,70 @@ func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string
return result.UploadID, nil
}
// getPartPresignedURL gets a presigned URL for uploading a specific part
func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) {
// PartUploadInfo contains structured information for uploading a part
type PartUploadInfo struct {
URL string `json:"url"`
Method string `json:"method,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}
// getPartUploadInfo gets structured upload info for uploading a specific part via XRPC
func (p *ProxyBlobStore) getPartUploadInfo(ctx context.Context, digest, uploadID string, partNumber int) (*PartUploadInfo, error) {
reqBody := map[string]any{
"did": p.did,
"digest": digest,
"upload_id": uploadID,
"part_number": partNumber,
"action": "part",
"uploadId": uploadID,
"partNumber": partNumber,
"digest": digest,
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", err
return nil, err
}
url := fmt.Sprintf("%s/part-presigned-url", p.storageEndpoint)
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", err
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("get part URL failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
return nil, fmt.Errorf("get part URL failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
var uploadInfo PartUploadInfo
if err := json.NewDecoder(resp.Body).Decode(&uploadInfo); err != nil {
return nil, err
}
return result.URL, nil
return &uploadInfo, nil
}
// completeMultipartUpload completes a multipart upload via hold service
// completeMultipartUpload completes a multipart upload via XRPC uploadBlob endpoint
// The XRPC complete action handles the move from temp to final location internally
func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error {
// Convert parts to XRPC format (partNumber instead of part_number)
xrpcParts := make([]map[string]any, len(parts))
for i, part := range parts {
xrpcParts[i] = map[string]any{
"partNumber": part.PartNumber,
"etag": part.ETag,
}
}
reqBody := map[string]any{
"did": p.did,
"digest": digest,
"upload_id": uploadID,
"parts": parts,
"action": "complete",
"uploadId": uploadID,
"digest": digest,
"parts": xrpcParts,
}
body, err := json.Marshal(reqBody)
@@ -509,7 +478,7 @@ func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, digest, up
return err
}
url := fmt.Sprintf("%s/complete-multipart", p.storageEndpoint)
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return err
@@ -530,12 +499,12 @@ func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, digest, up
return nil
}
// abortMultipartUpload aborts a multipart upload via hold service
// abortMultipartUpload aborts a multipart upload via XRPC uploadBlob endpoint
func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, digest, uploadID string) error {
reqBody := map[string]any{
"did": p.did,
"digest": digest,
"upload_id": uploadID,
"action": "abort",
"uploadId": uploadID,
"digest": digest,
}
body, err := json.Marshal(reqBody)
@@ -543,7 +512,7 @@ func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, digest, uploa
return err
}
url := fmt.Sprintf("%s/abort-multipart", p.storageEndpoint)
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return err
@@ -624,20 +593,31 @@ func (w *ProxyBlobWriter) flushPart() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Get presigned URL for this part
// Get structured upload info for this part
tempDigest := fmt.Sprintf("uploads/temp-%s", w.id)
url, err := w.store.getPartPresignedURL(ctx, tempDigest, w.uploadID, w.partNumber)
uploadInfo, err := w.store.getPartUploadInfo(ctx, tempDigest, w.uploadID, w.partNumber)
if err != nil {
return fmt.Errorf("failed to get part presigned URL: %w", err)
return fmt.Errorf("failed to get part upload info: %w", err)
}
// Upload part to S3
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(w.buffer.Bytes()))
// Determine HTTP method (default to PUT)
method := uploadInfo.Method
if method == "" {
method = "PUT"
}
// Upload part (either to S3 presigned URL or back to XRPC with headers)
req, err := http.NewRequestWithContext(ctx, method, uploadInfo.URL, bytes.NewReader(w.buffer.Bytes()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/octet-stream")
// Apply any additional headers from the response (for buffered mode)
for key, value := range uploadInfo.Headers {
req.Header.Set(key, value)
}
resp, err := w.store.httpClient.Do(req)
if err != nil {
return err
@@ -650,9 +630,18 @@ func (w *ProxyBlobWriter) flushPart() error {
}
// Store ETag for completion
// For buffered mode, ETag might be in JSON response body
etag := resp.Header.Get("ETag")
if etag == "" {
return fmt.Errorf("no ETag in response")
// Try to parse JSON response for buffered mode
var result struct {
ETag string `json:"etag"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.ETag != "" {
etag = result.ETag
} else {
return fmt.Errorf("no ETag in response")
}
}
w.parts = append(w.parts, CompletedPart{
@@ -727,37 +716,13 @@ func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descript
}
}
// Complete multipart upload at temp location
// Complete multipart upload - XRPC complete action handles move internally
tempDigest := fmt.Sprintf("uploads/temp-%s", w.id)
fmt.Printf("🔒 [Commit] Completing multipart upload: uploadID=%s, parts=%d\n", w.uploadID, len(w.parts))
if err := w.store.completeMultipartUpload(ctx, tempDigest, w.uploadID, w.parts); err != nil {
return distribution.Descriptor{}, fmt.Errorf("failed to complete multipart upload: %w", err)
}
// Move from temp → final location (server-side S3 copy)
tempPath := fmt.Sprintf("uploads/temp-%s", w.id)
finalPath := desc.Digest.String()
fmt.Printf("[Commit] Moving blob: %s → %s\n", tempPath, finalPath)
moveURL := fmt.Sprintf("%s/move?from=%s&to=%s&did=%s",
w.store.storageEndpoint, tempPath, finalPath, w.store.did)
req, err := http.NewRequestWithContext(ctx, "POST", moveURL, nil)
if err != nil {
return distribution.Descriptor{}, fmt.Errorf("failed to create move request: %w", err)
}
resp, err := w.store.httpClient.Do(req)
if err != nil {
return distribution.Descriptor{}, fmt.Errorf("failed to move blob: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return distribution.Descriptor{}, fmt.Errorf("move blob failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
fmt.Printf("[Commit] Upload completed successfully: digest=%s, size=%d, parts=%d\n", desc.Digest, w.size, len(w.parts))
return distribution.Descriptor{
+1 -1
View File
@@ -25,7 +25,7 @@ type Client struct {
did string
accessToken string // For Basic Auth only
httpClient *http.Client
useIndigoClient bool // true if using indigo's OAuth client (handles auth automatically)
useIndigoClient bool // true if using indigo's OAuth client (handles auth automatically)
indigoClient *atclient.APIClient // indigo's API client for OAuth requests
}
+6 -6
View File
@@ -394,12 +394,12 @@ func ResolveHoldDIDFromURL(holdURL string) string {
// Uses CBOR encoding for efficient storage in hold's carstore
type CaptainRecord struct {
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
}
// CrewRecord represents a crew member in the hold
+4 -4
View File
@@ -544,10 +544,10 @@ func (a *RemoteHoldAuthorizer) cacheDenial(holdDID, userDID string) error {
// This function handles second+ denials: 1m, 5m, 15m, 1h
func getBackoffDuration(denialCount int) time.Duration {
backoffs := []time.Duration{
1 * time.Minute, // 1st DB denial (2nd overall) - being added soon
5 * time.Minute, // 2nd DB denial (3rd overall) - probably not happening
15 * time.Minute, // 3rd DB denial (4th overall) - definitely not soon
60 * time.Minute, // 4th+ DB denial (5th+ overall) - stop hammering
1 * time.Minute, // 1st DB denial (2nd overall) - being added soon
5 * time.Minute, // 2nd DB denial (3rd overall) - probably not happening
15 * time.Minute, // 3rd DB denial (4th overall) - definitely not soon
60 * time.Minute, // 4th+ DB denial (5th+ overall) - stop hammering
}
idx := denialCount - 1
+1 -1
View File
@@ -129,7 +129,7 @@ func RedirectURI(baseURL string) string {
func GetDefaultScopes() []string {
return []string{
"atproto",
"blob:application/vnd.oci.image.manifest.v1+json",
"blob:application/vnd.oci.image.manifest.v1+json",
"blob:application/vnd.docker.distribution.manifest.v2+json",
fmt.Sprintf("repo:%s", atproto.ManifestCollection),
fmt.Sprintf("repo:%s", atproto.TagCollection),
+4 -4
View File
@@ -18,10 +18,10 @@ import (
// Handler handles /auth/token requests
type Handler struct {
issuer *Issuer
validator *atproto.SessionValidator
deviceStore *db.DeviceStore // For validating device secrets
defaultHoldDID string
issuer *Issuer
validator *atproto.SessionValidator
deviceStore *db.DeviceStore // For validating device secrets
defaultHoldDID string
}
// NewHandler creates a new token handler
+24 -4
View File
@@ -81,14 +81,34 @@ func (b *HoldServiceBlobStore) StartMultipartUpload(ctx context.Context, digest
return uploadID, modeStr, nil
}
// GetPartUploadURL returns a presigned URL for uploading a specific part
func (b *HoldServiceBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (string, error) {
// GetPartUploadURL returns structured upload info for uploading a specific part
func (b *HoldServiceBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (*pds.PartUploadInfo, error) {
session, err := b.service.MultipartMgr.GetSession(uploadID)
if err != nil {
return "", err
return nil, err
}
return b.service.GetPartUploadURL(ctx, session, partNumber, did)
// For S3Native mode: return presigned URL
if session.Mode == S3Native {
url, err := b.service.GetPartUploadURL(ctx, session, partNumber, did)
if err != nil {
return nil, err
}
return &pds.PartUploadInfo{
URL: url,
Method: "PUT",
}, nil
}
// Buffered mode: return XRPC endpoint with headers
return &pds.PartUploadInfo{
URL: fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", b.service.config.Server.PublicURL),
Method: "PUT",
Headers: map[string]string{
"X-Upload-Id": uploadID,
"X-Part-Number": fmt.Sprintf("%d", partNumber),
},
}, nil
}
// CompleteMultipartUpload finalizes a multipart upload
+4 -494
View File
@@ -1,496 +1,6 @@
package hold
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
)
// PresignedURLOperation defines the type of presigned URL operation
type PresignedURLOperation string
const (
OperationGet PresignedURLOperation = "GET"
OperationHead PresignedURLOperation = "HEAD"
OperationPut PresignedURLOperation = "PUT"
)
// PresignedURLRequest represents a request for a presigned URL (GET, HEAD, or PUT)
type PresignedURLRequest struct {
Operation PresignedURLOperation `json:"operation"`
DID string `json:"did"`
Digest string `json:"digest"`
Size int64 `json:"size,omitempty"` // Only required for PUT operations
}
// PresignedURLResponse contains the presigned URL
type PresignedURLResponse struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
}
// HandlePresignedURL handles presigned URL requests (GET, HEAD, or PUT)
// Operation type is specified in the request body
func (s *HoldService) HandlePresignedURL(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req PresignedURLRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
// Validate DID authorization based on operation type
var authorized bool
switch req.Operation {
case OperationGet, OperationHead:
authorized = s.isAuthorizedRead(req.DID)
case OperationPut:
authorized = s.isAuthorizedWrite(req.DID)
default:
http.Error(w, "unsupported operation", http.StatusBadRequest)
return
}
if !authorized {
log.Printf("[HandlePresignedURL:%s] Authorization FAILED", req.Operation)
if req.DID == "" {
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
} else {
http.Error(w, "forbidden: access denied", http.StatusForbidden)
}
return
}
// Generate presigned URL (15 minute expiry)
ctx := context.Background()
expiry := time.Now().Add(15 * time.Minute)
url, err := s.getPresignedURL(ctx, req.Operation, req.Digest, req.DID)
if err != nil {
log.Printf("[HandlePresignedURL:%s] getPresignedURL failed: %v", req.Operation, err)
http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError)
return
}
log.Printf("[HandlePresignedURL:%s] Returning URL to client", req.Operation)
resp := PresignedURLResponse{
URL: url,
ExpiresAt: expiry,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// HandleProxyGet proxies a blob download through the service
func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract digest from path (e.g., /blobs/sha256:abc123)
digest := r.URL.Path[len("/blobs/"):]
if digest == "" {
http.Error(w, "missing digest", http.StatusBadRequest)
return
}
// Get DID from query param or header
did := r.URL.Query().Get("did")
if did == "" {
did = r.Header.Get("X-ATCR-DID")
}
log.Printf(" DID: %s", did)
// Authorize READ access
if !s.isAuthorizedRead(did) {
log.Printf("[HandleProxyGet] Authorization FAILED")
if did == "" {
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
} else {
http.Error(w, "forbidden: access denied", http.StatusForbidden)
}
return
}
ctx := r.Context()
path := blobPath(digest)
// For HEAD requests, just check if blob exists
if r.Method == http.MethodHead {
stat, err := s.driver.Stat(ctx, path)
if err != nil {
http.Error(w, "blob not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
w.WriteHeader(http.StatusOK)
return
}
// For GET requests, read and return the blob
content, err := s.driver.GetContent(ctx, path)
if err != nil {
http.Error(w, "blob not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(content)
}
// HandleMove moves a blob from one path to another
// POST /move?from={path}&to={digest}&did={did}
func (s *HoldService) HandleMove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
fromPath := r.URL.Query().Get("from")
toDigest := r.URL.Query().Get("to")
did := r.URL.Query().Get("did")
if fromPath == "" || toDigest == "" {
http.Error(w, "missing from or to parameter", http.StatusBadRequest)
return
}
// Authorize WRITE access
if !s.isAuthorizedWrite(did) {
if did == "" {
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
} else {
http.Error(w, "forbidden: write access denied", http.StatusForbidden)
}
return
}
ctx := r.Context()
sourcePath := blobPath(fromPath)
destPath := blobPath(toDigest)
// Try to move using driver's Move operation
if err := s.driver.Move(ctx, sourcePath, destPath); err != nil {
log.Printf("HandleMove: failed to move blob: %v", err)
http.Error(w, fmt.Sprintf("failed to move blob: %v", err), http.StatusInternalServerError)
return
}
log.Printf("HandleMove: successfully moved blob from=%s to=%s", fromPath, toDigest)
w.WriteHeader(http.StatusOK)
}
// HandleProxyPut proxies a blob upload through the service
func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
digest := r.URL.Path[len("/blobs/"):]
if digest == "" {
http.Error(w, "missing digest", http.StatusBadRequest)
return
}
did := r.URL.Query().Get("did")
if did == "" {
did = r.Header.Get("X-ATCR-DID")
}
// Authorize WRITE access
if !s.isAuthorizedWrite(did) {
log.Printf("[HandleProxyPut] Authorization FAILED")
if did == "" {
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
} else {
http.Error(w, "forbidden: write access denied", http.StatusForbidden)
}
return
}
// Stream blob to storage (no buffering)
ctx := r.Context()
path := blobPath(digest)
// Create writer for streaming
writer, err := s.driver.Writer(ctx, path, false)
if err != nil {
log.Printf("HandleProxyPut: failed to create writer: %v", err)
http.Error(w, "failed to create writer", http.StatusInternalServerError)
return
}
// Stream directly from request body to storage
written, err := io.Copy(writer, r.Body)
if err != nil {
writer.Cancel(ctx)
log.Printf("HandleProxyPut: failed to write blob: %v", err)
http.Error(w, "failed to write blob", http.StatusInternalServerError)
return
}
// Commit the write
if err := writer.Commit(ctx); err != nil {
log.Printf("HandleProxyPut: failed to commit blob: %v", err)
http.Error(w, "failed to commit blob", http.StatusInternalServerError)
return
}
log.Printf("HandleProxyPut: successfully stored blob path=%s, size=%d", digest, written)
w.WriteHeader(http.StatusCreated)
}
// StartMultipartUploadRequest initiates a multipart upload
type StartMultipartUploadRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
}
// StartMultipartUploadResponse contains the multipart upload ID
type StartMultipartUploadResponse struct {
UploadID string `json:"upload_id"`
ExpiresAt time.Time `json:"expires_at"`
}
// HandleStartMultipart initiates a multipart upload
func (s *HoldService) HandleStartMultipart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req StartMultipartUploadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
// Validate DID authorization for WRITE
if !s.isAuthorizedWrite(req.DID) {
if req.DID == "" {
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
} else {
http.Error(w, "forbidden: write access denied", http.StatusForbidden)
}
return
}
// Start multipart upload with manager (supports both S3Native and Buffered modes)
ctx := r.Context()
uploadID, mode, err := s.StartMultipartUploadWithManager(ctx, req.Digest, s.MultipartMgr)
if err != nil {
http.Error(w, fmt.Sprintf("failed to start multipart upload: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Started multipart upload: uploadID=%s, mode=%v, digest=%s", uploadID, mode, req.Digest)
expiry := time.Now().Add(24 * time.Hour) // Multipart uploads can take longer
resp := StartMultipartUploadResponse{
UploadID: uploadID,
ExpiresAt: expiry,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// GetPartURLRequest requests a presigned URL for a specific part
type GetPartURLRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
UploadID string `json:"upload_id"`
PartNumber int `json:"part_number"`
}
// GetPartURLResponse contains the presigned URL for a part
type GetPartURLResponse struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
}
// HandleGetPartURL generates a presigned URL for uploading a specific part
func (s *HoldService) HandleGetPartURL(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req GetPartURLRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
// Validate DID authorization for WRITE
if !s.isAuthorizedWrite(req.DID) {
if req.DID == "" {
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
} else {
http.Error(w, "forbidden: write access denied", http.StatusForbidden)
}
return
}
// Get multipart session
session, err := s.MultipartMgr.GetSession(req.UploadID)
if err != nil {
http.Error(w, fmt.Sprintf("session not found: %v", err), http.StatusNotFound)
return
}
// Get part upload URL (presigned for S3Native, proxy for Buffered)
ctx := r.Context()
url, err := s.GetPartUploadURL(ctx, session, req.PartNumber, req.DID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate part URL: %v", err), http.StatusInternalServerError)
return
}
expiry := time.Now().Add(15 * time.Minute)
resp := GetPartURLResponse{
URL: url,
ExpiresAt: expiry,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// CompleteMultipartRequest completes a multipart upload
type CompleteMultipartRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
UploadID string `json:"upload_id"`
Parts []CompletedPart `json:"parts"`
}
// CompletedPart represents an uploaded part with its ETag
type CompletedPart struct {
PartNumber int `json:"part_number"`
ETag string `json:"etag"`
}
// HandleCompleteMultipart completes a multipart upload
func (s *HoldService) HandleCompleteMultipart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req CompleteMultipartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
// Validate DID authorization for WRITE
if !s.isAuthorizedWrite(req.DID) {
if req.DID == "" {
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
} else {
http.Error(w, "forbidden: write access denied", http.StatusForbidden)
}
return
}
// Get multipart session
session, err := s.MultipartMgr.GetSession(req.UploadID)
if err != nil {
http.Error(w, fmt.Sprintf("session not found: %v", err), http.StatusNotFound)
return
}
// For S3Native mode, use parts from request (uploaded directly to S3)
// For Buffered mode, parts are in the session
if session.Mode == S3Native {
// Record parts from AppView's request (they have ETags from S3)
for _, p := range req.Parts {
session.RecordS3Part(p.PartNumber, p.ETag, 0)
}
log.Printf("Recorded %d S3 parts from request for uploadID=%s", len(req.Parts), req.UploadID)
}
// Complete multipart upload (handles both S3Native and Buffered modes)
ctx := r.Context()
if err := s.CompleteMultipartUploadWithManager(ctx, session, s.MultipartMgr); err != nil {
http.Error(w, fmt.Sprintf("failed to complete multipart upload: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Completed multipart upload: uploadID=%s, mode=%v", req.UploadID, session.Mode)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "completed",
})
}
// AbortMultipartRequest aborts an in-progress upload
type AbortMultipartRequest struct {
DID string `json:"did"`
Digest string `json:"digest"`
UploadID string `json:"upload_id"`
}
// HandleAbortMultipart aborts an in-progress multipart upload
func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req AbortMultipartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest)
return
}
// Validate DID authorization for WRITE
if !s.isAuthorizedWrite(req.DID) {
if req.DID == "" {
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
} else {
http.Error(w, "forbidden: write access denied", http.StatusForbidden)
}
return
}
// Get multipart session
session, err := s.MultipartMgr.GetSession(req.UploadID)
if err != nil {
http.Error(w, fmt.Sprintf("session not found: %v", err), http.StatusNotFound)
return
}
// Abort multipart upload (handles both S3Native and Buffered modes)
ctx := r.Context()
if err := s.AbortMultipartUploadWithManager(ctx, session, s.MultipartMgr); err != nil {
http.Error(w, fmt.Sprintf("failed to abort multipart upload: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Aborted multipart upload: uploadID=%s, mode=%v", req.UploadID, session.Mode)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "aborted",
})
}
// This file previously contained legacy HTTP handlers that have been replaced by XRPC endpoints.
// The handlers (HandleProxyGet, HandleProxyPut, HandleMultipartPartUpload) are no longer needed
// as all blob operations now go through the XRPC com.atproto.repo.uploadBlob and
// com.atproto.sync.getBlob endpoints.
+8
View File
@@ -24,6 +24,12 @@ const (
Buffered
)
// CompletedPart represents an uploaded part with its ETag
type CompletedPart struct {
PartNumber int `json:"part_number"`
ETag string `json:"etag"`
}
// MultipartSession tracks an in-progress multipart upload
type MultipartSession struct {
UploadID string // Unique upload ID
@@ -270,6 +276,8 @@ func (s *HoldService) GetPartUploadURL(ctx context.Context, session *MultipartSe
}
// Buffered mode: return proxy endpoint
// url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", s.config.Server.PublicURL)
url := fmt.Sprintf("%s/multipart-parts/%s/%d?did=%s",
s.config.Server.PublicURL, session.UploadID, partNumber, did)
return url, nil
+116 -11
View File
@@ -7,12 +7,18 @@ import (
"fmt"
"io"
"net/http"
"slices"
"strings"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// HTTPClient interface allows injecting a custom HTTP client for testing
type HTTPClient interface {
Do(*http.Request) (*http.Response, error)
}
// ValidatedUser represents a successfully validated user from DPoP + OAuth
type ValidatedUser struct {
DID string
@@ -27,7 +33,10 @@ type ValidatedUser struct {
// 2. Extract DPoP header (proof JWT)
// 3. Call user's PDS to validate token via com.atproto.server.getSession
// 4. Return validated user DID
func ValidateDPoPRequest(r *http.Request) (*ValidatedUser, error) {
//
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
// This allows tests to inject a mock HTTP client.
func ValidateDPoPRequest(r *http.Request, httpClient HTTPClient) (*ValidatedUser, error) {
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
@@ -72,7 +81,7 @@ func ValidateDPoPRequest(r *http.Request) (*ValidatedUser, error) {
}
// Validate token with the user's PDS
session, err := validateTokenWithPDS(r.Context(), pds, accessToken, dpopProof)
session, err := validateTokenWithPDS(r.Context(), pds, accessToken, dpopProof, httpClient)
if err != nil {
return nil, fmt.Errorf("token validation failed: %w", err)
}
@@ -139,7 +148,8 @@ type SessionResponse struct {
}
// validateTokenWithPDS calls the user's PDS to validate the token
func validateTokenWithPDS(ctx context.Context, pdsURL, accessToken, dpopProof string) (*SessionResponse, error) {
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
func validateTokenWithPDS(ctx context.Context, pdsURL, accessToken, dpopProof string, httpClient HTTPClient) (*SessionResponse, error) {
// Call com.atproto.server.getSession with DPoP headers
url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", strings.TrimSuffix(pdsURL, "/"))
@@ -152,7 +162,13 @@ func validateTokenWithPDS(ctx context.Context, pdsURL, accessToken, dpopProof st
req.Header.Set("Authorization", "DPoP "+accessToken)
req.Header.Set("DPoP", dpopProof)
resp, err := http.DefaultClient.Do(req)
// Use provided client or default to http.DefaultClient
client := httpClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call PDS: %w", err)
}
@@ -194,10 +210,11 @@ func ResolveDIDToPDS(ctx context.Context, did string) (string, error) {
}
// ValidateOwnerOrCrewAdmin validates that the request has valid DPoP + OAuth tokens
// and that the authenticated user is either the hold owner or a crew member with crew:admin permission
func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS) (*ValidatedUser, error) {
// and that the authenticated user is either the hold owner or a crew member with crew:admin permission.
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
// Validate DPoP + OAuth token
user, err := ValidateDPoPRequest(r)
user, err := ValidateDPoPRequest(r, httpClient)
if err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
@@ -222,10 +239,8 @@ func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS) (*ValidatedUser, er
for _, member := range crew {
if member.Record.Member == user.DID {
// Check if this crew member has crew:admin permission
for _, perm := range member.Record.Permissions {
if perm == "crew:admin" {
return user, nil
}
if slices.Contains(member.Record.Permissions, "crew:admin") {
return user, nil
}
// User is crew but doesn't have admin permission
return nil, fmt.Errorf("crew member lacks required 'crew:admin' permission")
@@ -235,3 +250,93 @@ func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS) (*ValidatedUser, er
// User is neither owner nor authorized crew
return nil, fmt.Errorf("user is not authorized (must be hold owner or crew admin)")
}
// ValidateBlobWriteAccess validates that the request has valid DPoP + OAuth tokens
// and that the authenticated user is either the hold owner or a crew member with blob:write permission.
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
// Validate DPoP + OAuth token
user, err := ValidateDPoPRequest(r, httpClient)
if err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
// Get captain record to check owner and public settings
_, captain, err := pds.GetCaptainRecord(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to get captain record: %w", err)
}
// Check if user is the owner (always has write access)
if user.DID == captain.Owner {
return user, nil
}
// Check if user is crew with blob:write permission
crew, err := pds.ListCrewMembers(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to check crew membership: %w", err)
}
for _, member := range crew {
if member.Record.Member == user.DID {
// Check if this crew member has blob:write permission
if slices.Contains(member.Record.Permissions, "blob:write") {
return user, nil
}
// User is crew but doesn't have write permission
return nil, fmt.Errorf("crew member lacks required 'blob:write' permission")
}
}
// User is neither owner nor authorized crew
return nil, fmt.Errorf("user is not authorized for blob write (must be hold owner or crew with blob:write permission)")
}
// ValidateBlobReadAccess validates that the request has read access to blobs
// If captain.public = true: No auth required (returns nil user to indicate public access)
// If captain.public = false: Requires valid DPoP + OAuth and (captain OR crew with blob:read permission).
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
func ValidateBlobReadAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
// Get captain record to check public setting
_, captain, err := pds.GetCaptainRecord(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to get captain record: %w", err)
}
// If hold is public, allow access without authentication
if captain.Public {
return nil, nil // nil user indicates public access
}
// Private hold - require authentication
user, err := ValidateDPoPRequest(r, httpClient)
if err != nil {
return nil, fmt.Errorf("authentication required for private hold: %w", err)
}
// Check if user is the owner (always has read access)
if user.DID == captain.Owner {
return user, nil
}
// Check if user is crew with blob:read permission
crew, err := pds.ListCrewMembers(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to check crew membership: %w", err)
}
for _, member := range crew {
if member.Record.Member == user.DID {
// Check if this crew member has blob:read permission
if slices.Contains(member.Record.Permissions, "blob:read") {
return user, nil
}
// User is crew but doesn't have read permission
return nil, fmt.Errorf("crew member lacks required 'blob:read' permission")
}
}
// User is neither owner nor authorized crew
return nil, fmt.Errorf("user is not authorized for blob read (must be hold owner or crew with blob:read permission)")
}
+587
View File
@@ -0,0 +1,587 @@
package pds
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
)
// Tests for authorization functions in auth.go
// mockPDSClient is a mock HTTP client that simulates a PDS server
// It validates DPoP tokens and returns session information
type mockPDSClient struct{}
func (m *mockPDSClient) Do(req *http.Request) (*http.Response, error) {
// Verify request is for getSession endpoint
if !strings.Contains(req.URL.Path, "/xrpc/com.atproto.server.getSession") {
return &http.Response{
StatusCode: http.StatusNotFound,
Body: http.NoBody,
}, nil
}
// Verify DPoP headers are present
authHeader := req.Header.Get("Authorization")
dpopHeader := req.Header.Get("DPoP")
if authHeader == "" || dpopHeader == "" {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: http.NoBody,
}, nil
}
// Extract access token from Authorization header
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "DPoP" {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: http.NoBody,
}, nil
}
accessToken := parts[1]
// Parse token to extract DID
did, _, err := extractDIDFromToken(accessToken)
if err != nil {
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: http.NoBody,
}, nil
}
// Return session response
session := SessionResponse{
DID: did,
Handle: strings.Replace(did, "did:plc:", "", 1) + ".test",
}
body, _ := json.Marshal(session)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(string(body))),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}
// DPoPTestHelper provides utilities for creating valid DPoP requests in tests
type DPoPTestHelper struct {
privKey atcrypto.PrivateKey
did string
pdsURL string
}
// NewDPoPTestHelper creates a new test helper for the given DID and PDS
func NewDPoPTestHelper(did, pdsURL string) (*DPoPTestHelper, error) {
// Generate a test P-256 key (required for OAuth DPoP)
// Note: ATProto uses K-256 for DID keys, but OAuth DPoP requires P-256
privKey, err := atcrypto.GeneratePrivateKeyP256()
if err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
return &DPoPTestHelper{
privKey: privKey,
did: did,
pdsURL: pdsURL,
}, nil
}
// CreateAccessToken creates a mock OAuth access token for testing
// This mimics what a real PDS would issue
func (h *DPoPTestHelper) CreateAccessToken() (string, error) {
// Create access token claims
claims := map[string]any{
"sub": h.did, // Subject (DID)
"iss": h.pdsURL, // Issuer (PDS URL)
"aud": "atcr", // Audience
"iat": time.Now().Unix(), // Issued at
"exp": time.Now().Add(1 * time.Hour).Unix(), // Expires in 1 hour
}
// For testing, we create a valid JWT structure without actually validating the signature
// The ValidateDPoPRequest in real use would validate this by calling the PDS
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256K","typ":"JWT"}`))
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("failed to marshal claims: %w", err)
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
// Create a mock signature (in real use, the PDS validates this)
signature := base64.RawURLEncoding.EncodeToString([]byte("mock-signature-for-testing"))
tokenString := fmt.Sprintf("%s.%s.%s", header, encodedPayload, signature)
return tokenString, nil
}
// CreateDPoPProof creates a DPoP proof JWT for the given HTTP request
func (h *DPoPTestHelper) CreateDPoPProof(method, url string) (string, error) {
return oauth.NewAuthDPoP(method, url, "", h.privKey)
}
// AddDPoPToRequest adds proper DPoP headers to an HTTP request
func (h *DPoPTestHelper) AddDPoPToRequest(req *http.Request) error {
// Create access token
accessToken, err := h.CreateAccessToken()
if err != nil {
return fmt.Errorf("failed to create access token: %w", err)
}
// Create DPoP proof for this specific request
dpopProof, err := h.CreateDPoPProof(req.Method, req.URL.String())
if err != nil {
return fmt.Errorf("failed to create DPoP proof: %w", err)
}
// Add headers
req.Header.Set("Authorization", "DPoP "+accessToken)
req.Header.Set("DPoP", dpopProof)
return nil
}
// AddTestDPoP is a quick helper for common test case: owner with standard PDS
func AddTestDPoP(req *http.Request, did, pdsURL string) error {
helper, err := NewDPoPTestHelper(did, pdsURL)
if err != nil {
return err
}
return helper.AddDPoPToRequest(req)
}
// TestValidateBlobWriteAccess_Owner tests that the hold owner has write access
func TestValidateBlobWriteAccess_Owner(t *testing.T) {
pds, ctx := setupTestPDS(t)
ownerDID := "did:plc:owner123"
// Bootstrap with owner
err := pds.Bootstrap(ctx, ownerDID, true, false)
if err != nil {
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
// Create DPoP helper for owner
dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
// Create request with proper DPoP tokens
req := httptest.NewRequest(http.MethodPost, "/test", nil)
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
// Use mock PDS client
mockClient := &mockPDSClient{}
// Test owner has write access
user, err := ValidateBlobWriteAccess(req, pds, mockClient)
if err != nil {
t.Errorf("Expected owner to have write access, got error: %v", err)
}
if user == nil {
t.Fatal("Expected non-nil user")
}
if user.DID != ownerDID {
t.Errorf("Expected DID %s, got %s", ownerDID, user.DID)
}
if !user.Authorized {
t.Error("Expected user to be authorized")
}
}
// TestValidateBlobWriteAccess_CrewPermissions tests crew permission checking
func TestValidateBlobWriteAccess_CrewPermissions(t *testing.T) {
pds, ctx := setupTestPDS(t)
ownerDID := "did:plc:owner123"
// Bootstrap
err := pds.Bootstrap(ctx, ownerDID, true, false)
if err != nil {
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
// Add crew member with blob:write permission
writerDID := "did:plc:writer123"
_, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
// Add crew member without blob:write permission
readerDID := "did:plc:reader123"
_, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
mockClient := &mockPDSClient{}
// Test writer (has blob:write permission) can write
t.Run("crew with blob:write can write", func(t *testing.T) {
dpopHelper, err := NewDPoPTestHelper(writerDID, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/test", nil)
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
user, err := ValidateBlobWriteAccess(req, pds, mockClient)
if err != nil {
t.Errorf("Expected writer to have write access, got error: %v", err)
}
if user == nil || user.DID != writerDID {
t.Errorf("Expected user DID %s, got %v", writerDID, user)
}
})
// Test reader (no blob:write permission) cannot write
t.Run("crew without blob:write cannot write", func(t *testing.T) {
dpopHelper, err := NewDPoPTestHelper(readerDID, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/test", nil)
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
_, err = ValidateBlobWriteAccess(req, pds, mockClient)
if err == nil {
t.Error("Expected reader without blob:write permission to be denied")
}
if !strings.Contains(err.Error(), "blob:write") {
t.Errorf("Expected error about blob:write permission, got: %v", err)
}
})
}
// TestValidateBlobReadAccess_PublicHold tests public hold access
func TestValidateBlobReadAccess_PublicHold(t *testing.T) {
pds, ctx := setupTestPDS(t)
ownerDID := "did:plc:owner123"
// Bootstrap with public=true
err := pds.Bootstrap(ctx, ownerDID, true, false)
if err != nil {
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
// Verify captain record has public=true
_, captain, err := pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record: %v", err)
}
if !captain.Public {
t.Error("Expected public=true for captain record")
}
// Create request without auth headers (anonymous user)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
// This should return nil (public access allowed) for public holds
user, err := ValidateBlobReadAccess(req, pds, nil)
if err != nil {
t.Errorf("Expected public access for public hold, got error: %v", err)
}
// nil user indicates public access
if user != nil {
t.Error("Expected nil user for public access")
}
}
// TestValidateBlobReadAccess_PrivateHold tests private hold access
func TestValidateBlobReadAccess_PrivateHold(t *testing.T) {
pds, ctx := setupTestPDS(t)
ownerDID := "did:plc:owner123"
// Bootstrap with public=false
err := pds.Bootstrap(ctx, ownerDID, false, false)
if err != nil {
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
// Update captain to be private
_, err = pds.UpdateCaptainRecord(ctx, false, false)
if err != nil {
t.Fatalf("Failed to update captain record: %v", err)
}
// Verify captain record has public=false
_, captain, err := pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record: %v", err)
}
if captain.Public {
t.Error("Expected public=false for captain record")
}
// Create request without auth headers (anonymous user)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
// This should return error (auth required) for private holds
user, err := ValidateBlobReadAccess(req, pds, nil)
if err == nil {
t.Error("Expected error for private hold without auth")
}
if user != nil {
t.Error("Expected nil user when auth fails")
}
}
// TestValidateOwnerOrCrewAdmin tests admin permission checking
func TestValidateOwnerOrCrewAdmin(t *testing.T) {
pds, ctx := setupTestPDS(t)
ownerDID := "did:plc:owner123"
// Bootstrap
err := pds.Bootstrap(ctx, ownerDID, true, false)
if err != nil {
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
// Add crew member with crew:admin permission
adminDID := "did:plc:admin123"
_, err = pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"})
if err != nil {
t.Fatalf("Failed to add crew admin: %v", err)
}
// Add crew member without crew:admin permission
writerDID := "did:plc:writer123"
_, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
if err != nil {
t.Fatalf("Failed to add crew writer: %v", err)
}
// Verify crew records were created
crew, err := pds.ListCrewMembers(ctx)
if err != nil {
t.Fatalf("Failed to list crew members: %v", err)
}
// Verify admin has crew:admin permission
hasAdminPermission := false
for _, member := range crew {
if member.Record.Member == adminDID {
if slices.Contains(member.Record.Permissions, "crew:admin") {
hasAdminPermission = true
}
}
}
if !hasAdminPermission {
t.Error("Admin crew member should have crew:admin permission")
}
// Verify writer does NOT have crew:admin permission
writerHasAdminPermission := false
for _, member := range crew {
if member.Record.Member == writerDID {
if slices.Contains(member.Record.Permissions, "crew:admin") {
writerHasAdminPermission = true
}
}
}
if writerHasAdminPermission {
t.Error("Writer crew member should NOT have crew:admin permission")
}
// Test that function requires auth (will fail without DPoP tokens)
req := httptest.NewRequest(http.MethodPost, "/test", nil)
_, err = ValidateOwnerOrCrewAdmin(req, pds, nil)
if err == nil {
t.Error("Expected error for missing auth headers")
}
}
// TestCrewPermissions tests various permission combinations
func TestCrewPermissions(t *testing.T) {
pds, ctx := setupTestPDS(t)
ownerDID := "did:plc:owner123"
// Bootstrap
err := pds.Bootstrap(ctx, ownerDID, true, false)
if err != nil {
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
tests := []struct {
name string
did string
role string
permissions []string
}{
{
name: "full admin",
did: "did:plc:fulladmin",
role: "admin",
permissions: []string{"crew:admin", "blob:write", "blob:read"},
},
{
name: "writer only",
did: "did:plc:writer",
role: "writer",
permissions: []string{"blob:write"},
},
{
name: "reader only",
did: "did:plc:reader",
role: "reader",
permissions: []string{"blob:read"},
},
{
name: "read-write",
did: "did:plc:readwrite",
role: "editor",
permissions: []string{"blob:read", "blob:write"},
},
}
// Add all crew members
for _, tt := range tests {
_, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions)
if err != nil {
t.Fatalf("Failed to add crew member %s: %v", tt.name, err)
}
}
// Verify all crew members were created
crew, err := pds.ListCrewMembers(ctx)
if err != nil {
t.Fatalf("Failed to list crew members: %v", err)
}
// Should have: 1 owner (from bootstrap) + 4 test crew members
expectedCount := len(tests) + 1
if len(crew) != expectedCount {
t.Errorf("Expected %d crew members (owner + %d test members), got %d",
expectedCount, len(tests), len(crew))
}
// Verify each crew member has the expected permissions
for _, tt := range tests {
found := false
for _, member := range crew {
if member.Record.Member == tt.did {
found = true
// Check that all expected permissions are present
for _, expectedPerm := range tt.permissions {
hasPerm := slices.Contains(member.Record.Permissions, expectedPerm)
if !hasPerm {
t.Errorf("Crew member %s missing expected permission %s",
tt.name, expectedPerm)
}
}
// Verify role
if member.Record.Role != tt.role {
t.Errorf("Crew member %s has role %s, expected %s",
tt.name, member.Record.Role, tt.role)
}
}
}
if !found {
t.Errorf("Crew member %s not found in list", tt.name)
}
}
}
// TestCaptainRecordSettings tests captain record public/allowAllCrew settings
func TestCaptainRecordSettings(t *testing.T) {
tests := []struct {
name string
public bool
allowAllCrew bool
}{
{
name: "public hold, crew approval required",
public: true,
allowAllCrew: false,
},
{
name: "public hold, open crew",
public: true,
allowAllCrew: true,
},
{
name: "private hold, crew approval required",
public: false,
allowAllCrew: false,
},
{
name: "private hold, open crew",
public: false,
allowAllCrew: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pds, ctx := setupTestPDS(t)
ownerDID := "did:plc:owner123"
// Bootstrap with specified settings
err := pds.Bootstrap(ctx, ownerDID, tt.public, tt.allowAllCrew)
if err != nil {
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
// Verify captain record has expected settings
_, captain, err := pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record: %v", err)
}
if captain.Public != tt.public {
t.Errorf("Expected public=%v, got %v", tt.public, captain.Public)
}
if captain.AllowAllCrew != tt.allowAllCrew {
t.Errorf("Expected allowAllCrew=%v, got %v", tt.allowAllCrew, captain.AllowAllCrew)
}
if captain.Owner != ownerDID {
t.Errorf("Expected owner %s, got %s", ownerDID, captain.Owner)
}
})
}
}
+274
View File
@@ -0,0 +1,274 @@
package pds
import (
"context"
"encoding/json"
"path/filepath"
"testing"
)
// TestGenerateDIDFromURL tests DID generation from various URL formats
func TestGenerateDIDFromURL(t *testing.T) {
tests := []struct {
name string
publicURL string
expectedDID string
}{
{
name: "standard HTTP with standard port",
publicURL: "http://hold.example.com",
expectedDID: "did:web:hold.example.com",
},
{
name: "standard HTTPS with standard port",
publicURL: "https://hold.example.com",
expectedDID: "did:web:hold.example.com",
},
{
name: "HTTP with non-standard port",
publicURL: "http://hold.example.com:8080",
expectedDID: "did:web:hold.example.com:8080",
},
{
name: "HTTPS with non-standard port",
publicURL: "https://hold.example.com:8443",
expectedDID: "did:web:hold.example.com:8443",
},
{
name: "localhost with port",
publicURL: "http://localhost:8080",
expectedDID: "did:web:localhost:8080",
},
{
name: "HTTP with explicit port 80",
publicURL: "http://hold.example.com:80",
expectedDID: "did:web:hold.example.com",
},
{
name: "HTTPS with explicit port 443",
publicURL: "https://hold.example.com:443",
expectedDID: "did:web:hold.example.com",
},
{
name: "subdomain",
publicURL: "https://hold1.atcr.io",
expectedDID: "did:web:hold1.atcr.io",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
did := GenerateDIDFromURL(tt.publicURL)
if did != tt.expectedDID {
t.Errorf("Expected DID %s, got %s", tt.expectedDID, did)
}
})
}
}
// TestGenerateDIDFromURL_InvalidURL tests handling of invalid URLs
func TestGenerateDIDFromURL_InvalidURL(t *testing.T) {
// Invalid URLs get parsed with empty hostname, which defaults to localhost
did := GenerateDIDFromURL("not a url")
if did != "did:web:localhost" {
t.Errorf("Expected did:web:localhost for invalid URL, got %s", did)
}
}
// TestGenerateDIDDocument tests DID document generation
func TestGenerateDIDDocument(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, dbPath, keyPath)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
doc, err := pds.GenerateDIDDocument(publicURL)
if err != nil {
t.Fatalf("Failed to generate DID document: %v", err)
}
// Verify required fields
if doc.ID != "did:web:hold.example.com" {
t.Errorf("Expected DID did:web:hold.example.com, got %s", doc.ID)
}
// Verify context
if len(doc.Context) != 3 {
t.Errorf("Expected 3 context entries, got %d", len(doc.Context))
}
expectedContexts := []string{
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1",
}
for i, expected := range expectedContexts {
if doc.Context[i] != expected {
t.Errorf("Expected context[%d] = %s, got %s", i, expected, doc.Context[i])
}
}
// Verify alsoKnownAs
if len(doc.AlsoKnownAs) != 1 || doc.AlsoKnownAs[0] != "at://hold.example.com" {
t.Errorf("Expected alsoKnownAs=['at://hold.example.com'], got %v", doc.AlsoKnownAs)
}
// Verify verification method
if len(doc.VerificationMethod) != 1 {
t.Fatalf("Expected 1 verification method, got %d", len(doc.VerificationMethod))
}
vm := doc.VerificationMethod[0]
if vm.ID != "did:web:hold.example.com#atproto" {
t.Errorf("Expected verification method ID did:web:hold.example.com#atproto, got %s", vm.ID)
}
if vm.Type != "Multikey" {
t.Errorf("Expected type Multikey, got %s", vm.Type)
}
if vm.Controller != "did:web:hold.example.com" {
t.Errorf("Expected controller did:web:hold.example.com, got %s", vm.Controller)
}
if vm.PublicKeyMultibase == "" {
t.Error("Expected non-empty publicKeyMultibase")
}
// Verify authentication
if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:hold.example.com#atproto" {
t.Errorf("Expected authentication=['did:web:hold.example.com#atproto'], got %v", doc.Authentication)
}
// Verify services
if len(doc.Service) != 2 {
t.Fatalf("Expected 2 services, got %d", len(doc.Service))
}
// Check PDS service
pdsService := doc.Service[0]
if pdsService.ID != "#atproto_pds" {
t.Errorf("Expected service ID #atproto_pds, got %s", pdsService.ID)
}
if pdsService.Type != "AtprotoPersonalDataServer" {
t.Errorf("Expected service type AtprotoPersonalDataServer, got %s", pdsService.Type)
}
if pdsService.ServiceEndpoint != publicURL {
t.Errorf("Expected service endpoint %s, got %s", publicURL, pdsService.ServiceEndpoint)
}
// Check hold service
holdService := doc.Service[1]
if holdService.ID != "#atcr_hold" {
t.Errorf("Expected service ID #atcr_hold, got %s", holdService.ID)
}
if holdService.Type != "AtcrHoldService" {
t.Errorf("Expected service type AtcrHoldService, got %s", holdService.Type)
}
if holdService.ServiceEndpoint != publicURL {
t.Errorf("Expected service endpoint %s, got %s", publicURL, holdService.ServiceEndpoint)
}
}
// TestGenerateDIDDocument_WithPort tests DID document with non-standard port
func TestGenerateDIDDocument_WithPort(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com:8443"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com:8443", publicURL, dbPath, keyPath)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
doc, err := pds.GenerateDIDDocument(publicURL)
if err != nil {
t.Fatalf("Failed to generate DID document: %v", err)
}
// Verify DID includes port
if doc.ID != "did:web:hold.example.com:8443" {
t.Errorf("Expected DID did:web:hold.example.com:8443, got %s", doc.ID)
}
// Verify alsoKnownAs includes port
if doc.AlsoKnownAs[0] != "at://hold.example.com:8443" {
t.Errorf("Expected alsoKnownAs with port, got %s", doc.AlsoKnownAs[0])
}
}
// TestMarshalDIDDocument tests DID document JSON marshaling
func TestMarshalDIDDocument(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, dbPath, keyPath)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
jsonBytes, err := pds.MarshalDIDDocument()
if err != nil {
t.Fatalf("Failed to marshal DID document: %v", err)
}
// Verify it's valid JSON
var doc map[string]any
if err := json.Unmarshal(jsonBytes, &doc); err != nil {
t.Fatalf("Failed to unmarshal DID document JSON: %v", err)
}
// Verify required fields
if id, ok := doc["id"].(string); !ok || id != "did:web:hold.example.com" {
t.Errorf("Expected id='did:web:hold.example.com', got %v", doc["id"])
}
if _, ok := doc["@context"]; !ok {
t.Error("Expected @context field in JSON")
}
if _, ok := doc["verificationMethod"]; !ok {
t.Error("Expected verificationMethod field in JSON")
}
if _, ok := doc["service"]; !ok {
t.Error("Expected service field in JSON")
}
// Verify pretty-printed (has indentation)
if len(jsonBytes) < 100 {
t.Error("Expected pretty-printed JSON to be reasonably sized")
}
}
// TestGenerateDIDDocument_InvalidURL tests error handling
func TestGenerateDIDDocument_InvalidURL(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, dbPath, keyPath)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
// Try to generate DID document with invalid URL
_, err = pds.GenerateDIDDocument("ht!tp://invalid url")
if err == nil {
t.Error("Expected error for invalid URL, got nil")
}
}
+10 -10
View File
@@ -19,7 +19,7 @@ type EventBroadcaster struct {
eventSeq int64
eventHistory []HistoricalEvent // Ring buffer for cursor backfill
maxHistory int
holdDID string // DID of the hold for setting repo field
holdDID string // DID of the hold for setting repo field
}
// Subscriber represents a WebSocket client subscribed to the firehose
@@ -37,15 +37,15 @@ type HistoricalEvent struct {
// RepoCommitEvent represents a #commit event in subscribeRepos
type RepoCommitEvent struct {
Seq int64 `json:"seq" cborgen:"seq"`
Repo string `json:"repo" cborgen:"repo"`
Commit string `json:"commit" cborgen:"commit"` // CID string
Rev string `json:"rev" cborgen:"rev"`
Since *string `json:"since,omitempty" cborgen:"since,omitempty"`
Blocks []byte `json:"blocks" cborgen:"blocks"` // CAR slice bytes
Ops []*atproto.SyncSubscribeRepos_RepoOp `json:"ops" cborgen:"ops"`
Time string `json:"time" cborgen:"time"`
Type string `json:"$type" cborgen:"$type"` // Always "#commit"
Seq int64 `json:"seq" cborgen:"seq"`
Repo string `json:"repo" cborgen:"repo"`
Commit string `json:"commit" cborgen:"commit"` // CID string
Rev string `json:"rev" cborgen:"rev"`
Since *string `json:"since,omitempty" cborgen:"since,omitempty"`
Blocks []byte `json:"blocks" cborgen:"blocks"` // CAR slice bytes
Ops []*atproto.SyncSubscribeRepos_RepoOp `json:"ops" cborgen:"ops"`
Time string `json:"time" cborgen:"time"`
Type string `json:"$type" cborgen:"$type"` // Always "#commit"
}
// NewEventBroadcaster creates a new event broadcaster
+384
View File
@@ -0,0 +1,384 @@
package pds
import (
"context"
"encoding/json"
"testing"
"time"
atproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/ipfs/go-cid"
)
// TestNewEventBroadcaster tests event broadcaster creation
func TestNewEventBroadcaster(t *testing.T) {
holdDID := "did:web:hold.example.com"
broadcaster := NewEventBroadcaster(holdDID, 100)
if broadcaster.holdDID != holdDID {
t.Errorf("Expected holdDID=%s, got %s", holdDID, broadcaster.holdDID)
}
if broadcaster.eventSeq != 0 {
t.Errorf("Expected initial eventSeq=0, got %d", broadcaster.eventSeq)
}
if broadcaster.maxHistory != 100 {
t.Errorf("Expected maxHistory=100, got %d", broadcaster.maxHistory)
}
if len(broadcaster.subscribers) != 0 {
t.Errorf("Expected 0 subscribers initially, got %d", len(broadcaster.subscribers))
}
}
// TestNewEventBroadcaster_DefaultHistory tests default history size
func TestNewEventBroadcaster_DefaultHistory(t *testing.T) {
// Zero or negative maxHistory should default to 100
broadcaster := NewEventBroadcaster("did:web:test", 0)
if broadcaster.maxHistory != 100 {
t.Errorf("Expected default maxHistory=100 for input 0, got %d", broadcaster.maxHistory)
}
broadcaster2 := NewEventBroadcaster("did:web:test", -5)
if broadcaster2.maxHistory != 100 {
t.Errorf("Expected default maxHistory=100 for negative input, got %d", broadcaster2.maxHistory)
}
}
// TestGetCurrentSeq tests sequence number tracking
func TestGetCurrentSeq(t *testing.T) {
broadcaster := NewEventBroadcaster("did:web:hold.example.com", 10)
// Initial seq should be 0
seq := broadcaster.GetCurrentSeq()
if seq != 0 {
t.Errorf("Expected initial seq=0, got %d", seq)
}
// After broadcasting, seq should increment
ctx := context.Background()
testCID, _ := cid.Decode("bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke")
event := &RepoEvent{
NewRoot: testCID,
Rev: "test-rev-1",
RepoSlice: []byte("test CAR data"),
Ops: []RepoOp{
{
Kind: EvtKindCreateRecord,
Collection: "io.atcr.hold.crew",
Rkey: "test123",
},
},
}
broadcaster.Broadcast(ctx, event)
seq = broadcaster.GetCurrentSeq()
if seq != 1 {
t.Errorf("Expected seq=1 after one broadcast, got %d", seq)
}
// Broadcast again
broadcaster.Broadcast(ctx, event)
seq = broadcaster.GetCurrentSeq()
if seq != 2 {
t.Errorf("Expected seq=2 after two broadcasts, got %d", seq)
}
}
// TestBroadcast tests event broadcasting
func TestBroadcast(t *testing.T) {
broadcaster := NewEventBroadcaster("did:web:hold.example.com", 10)
ctx := context.Background()
testCID, _ := cid.Decode("bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke")
event := &RepoEvent{
NewRoot: testCID,
Rev: "test-rev-1",
RepoSlice: []byte("test CAR data"),
Ops: []RepoOp{
{
Kind: EvtKindCreateRecord,
Collection: "io.atcr.hold.crew",
Rkey: "test123",
RecCid: &testCID,
},
},
}
// Broadcast should not panic without subscribers
broadcaster.Broadcast(ctx, event)
// Verify sequence incremented
if broadcaster.eventSeq != 1 {
t.Errorf("Expected eventSeq=1, got %d", broadcaster.eventSeq)
}
// Verify event added to history
if len(broadcaster.eventHistory) != 1 {
t.Errorf("Expected 1 event in history, got %d", len(broadcaster.eventHistory))
}
he := broadcaster.eventHistory[0]
if he.Seq != 1 {
t.Errorf("Expected history seq=1, got %d", he.Seq)
}
if he.Event.Repo != "did:web:hold.example.com" {
t.Errorf("Expected repo=did:web:hold.example.com, got %s", he.Event.Repo)
}
if he.Event.Type != "#commit" {
t.Errorf("Expected type=#commit, got %s", he.Event.Type)
}
if len(he.Event.Ops) != 1 {
t.Errorf("Expected 1 op, got %d", len(he.Event.Ops))
}
}
// TestAddToHistory_RingBuffer tests ring buffer behavior
func TestAddToHistory_RingBuffer(t *testing.T) {
// Create broadcaster with small history
broadcaster := NewEventBroadcaster("did:web:hold.example.com", 3)
ctx := context.Background()
testCID, _ := cid.Decode("bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke")
// Broadcast 5 events (exceeds maxHistory of 3)
for i := 0; i < 5; i++ {
event := &RepoEvent{
NewRoot: testCID,
Rev: "test-rev",
RepoSlice: []byte("test CAR data"),
Ops: []RepoOp{},
}
broadcaster.Broadcast(ctx, event)
}
// Should only keep last 3 events
if len(broadcaster.eventHistory) != 3 {
t.Errorf("Expected 3 events in history (ring buffer), got %d", len(broadcaster.eventHistory))
}
// Verify we kept the most recent events (seq 3, 4, 5)
expectedSeqs := []int64{3, 4, 5}
for i, expected := range expectedSeqs {
if broadcaster.eventHistory[i].Seq != expected {
t.Errorf("Expected history[%d].Seq=%d, got %d", i, expected, broadcaster.eventHistory[i].Seq)
}
}
// Final sequence should be 5
if broadcaster.eventSeq != 5 {
t.Errorf("Expected eventSeq=5, got %d", broadcaster.eventSeq)
}
}
// TestConvertToCommitEvent tests event conversion
func TestConvertToCommitEvent(t *testing.T) {
broadcaster := NewEventBroadcaster("did:web:hold.example.com", 10)
testCID, _ := cid.Decode("bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke")
since := "prev-rev"
event := &RepoEvent{
NewRoot: testCID,
Rev: "test-rev-123",
Since: &since,
RepoSlice: []byte("test CAR data"),
Ops: []RepoOp{
{
Kind: EvtKindCreateRecord,
Collection: "io.atcr.hold.crew",
Rkey: "member1",
RecCid: &testCID,
},
{
Kind: EvtKindUpdateRecord,
Collection: "io.atcr.hold.captain",
Rkey: "self",
RecCid: &testCID,
},
{
Kind: EvtKindDeleteRecord,
Collection: "io.atcr.hold.crew",
Rkey: "oldmember",
RecCid: nil, // Deletes don't have CIDs
},
},
}
commitEvent := broadcaster.convertToCommitEvent(event, 42)
// Verify basic fields
if commitEvent.Seq != 42 {
t.Errorf("Expected seq=42, got %d", commitEvent.Seq)
}
if commitEvent.Repo != "did:web:hold.example.com" {
t.Errorf("Expected repo=did:web:hold.example.com, got %s", commitEvent.Repo)
}
if commitEvent.Commit != testCID.String() {
t.Errorf("Expected commit=%s, got %s", testCID.String(), commitEvent.Commit)
}
if commitEvent.Rev != "test-rev-123" {
t.Errorf("Expected rev=test-rev-123, got %s", commitEvent.Rev)
}
if commitEvent.Since == nil || *commitEvent.Since != since {
t.Errorf("Expected since=%s, got %v", since, commitEvent.Since)
}
if string(commitEvent.Blocks) != "test CAR data" {
t.Errorf("Expected blocks='test CAR data', got %s", string(commitEvent.Blocks))
}
if commitEvent.Type != "#commit" {
t.Errorf("Expected type=#commit, got %s", commitEvent.Type)
}
// Verify time is set
if commitEvent.Time == "" {
t.Error("Expected non-empty time")
}
// Parse time to verify it's valid RFC3339
_, err := time.Parse(time.RFC3339, commitEvent.Time)
if err != nil {
t.Errorf("Expected valid RFC3339 time, got error: %v", err)
}
// Verify ops conversion
if len(commitEvent.Ops) != 3 {
t.Fatalf("Expected 3 ops, got %d", len(commitEvent.Ops))
}
// Check create op
createOp := commitEvent.Ops[0]
if createOp.Action != "create" {
t.Errorf("Expected action=create, got %s", createOp.Action)
}
if createOp.Path != "io.atcr.hold.crew/member1" {
t.Errorf("Expected path=io.atcr.hold.crew/member1, got %s", createOp.Path)
}
if createOp.Cid == nil {
t.Error("Expected non-nil CID for create op")
}
// Check update op
updateOp := commitEvent.Ops[1]
if updateOp.Action != "update" {
t.Errorf("Expected action=update, got %s", updateOp.Action)
}
if updateOp.Path != "io.atcr.hold.captain/self" {
t.Errorf("Expected path=io.atcr.hold.captain/self, got %s", updateOp.Path)
}
// Check delete op
deleteOp := commitEvent.Ops[2]
if deleteOp.Action != "delete" {
t.Errorf("Expected action=delete, got %s", deleteOp.Action)
}
if deleteOp.Path != "io.atcr.hold.crew/oldmember" {
t.Errorf("Expected path=io.atcr.hold.crew/oldmember, got %s", deleteOp.Path)
}
if deleteOp.Cid != nil {
t.Error("Expected nil CID for delete op")
}
}
// TestConvertToCommitEvent_NoSince tests event without since field
func TestConvertToCommitEvent_NoSince(t *testing.T) {
broadcaster := NewEventBroadcaster("did:web:hold.example.com", 10)
testCID, _ := cid.Decode("bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke")
event := &RepoEvent{
NewRoot: testCID,
Rev: "test-rev-123",
Since: nil, // No since
RepoSlice: []byte("test CAR data"),
Ops: []RepoOp{},
}
commitEvent := broadcaster.convertToCommitEvent(event, 1)
if commitEvent.Since != nil {
t.Errorf("Expected nil since, got %v", commitEvent.Since)
}
}
// TestSetRepoEventHandler tests handler registration
func TestSetRepoEventHandler(t *testing.T) {
broadcaster := NewEventBroadcaster("did:web:hold.example.com", 10)
handler := broadcaster.SetRepoEventHandler()
if handler == nil {
t.Fatal("Expected non-nil handler")
}
// Call handler
ctx := context.Background()
testCID, _ := cid.Decode("bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke")
event := &RepoEvent{
NewRoot: testCID,
Rev: "test-rev",
RepoSlice: []byte("test CAR data"),
Ops: []RepoOp{},
}
handler(ctx, event)
// Verify event was broadcast
if broadcaster.eventSeq != 1 {
t.Errorf("Expected eventSeq=1 after handler call, got %d", broadcaster.eventSeq)
}
if len(broadcaster.eventHistory) != 1 {
t.Errorf("Expected 1 event in history after handler call, got %d", len(broadcaster.eventHistory))
}
}
// TestEncodeCBOR tests CBOR encoding (currently JSON)
func TestEncodeCBOR(t *testing.T) {
testCID, _ := cid.Decode("bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke")
event := &RepoCommitEvent{
Seq: 1,
Repo: "did:web:hold.example.com",
Commit: testCID.String(),
Rev: "test-rev",
Blocks: []byte("test data"),
Ops: []*atproto.SyncSubscribeRepos_RepoOp{},
Time: time.Now().Format(time.RFC3339),
Type: "#commit",
}
encoded, err := encodeCBOR(event)
if err != nil {
t.Fatalf("Failed to encode CBOR: %v", err)
}
if len(encoded) == 0 {
t.Error("Expected non-empty encoded data")
}
// Current implementation uses JSON, so verify it's valid JSON
// In future, this would be proper CBOR validation
var decoded RepoCommitEvent
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Errorf("Failed to decode JSON: %v", err)
}
if decoded.Seq != 1 {
t.Errorf("Expected decoded seq=1, got %d", decoded.Seq)
}
}
+8 -8
View File
@@ -5,14 +5,14 @@
// Reason: The indigo library is unmaintained and contains a critical bug in UpdateRecord
//
// Modifications from original:
// - Changed package from 'repomgr' to 'pds' for integration with hold service
// - Fixed UpdateRecord bug (line 263): Changed r.PutRecord to r.UpdateRecord
// (UpdateRecord was incorrectly calling PutRecord, causing incorrect MST operations)
// - Removed 5 Prometheus metrics calls (openAndSigCheckDuration, calcDiffDuration,
// writeCarSliceDuration, repoOpsImported) as metrics are not used in this project
// - Added PutRecord method (lines 309-381) for creating records with explicit rkeys
// (like CreateRecord but with specified rkey instead of auto-generated TID)
// Based on streamplace/indigo implementation
// - Changed package from 'repomgr' to 'pds' for integration with hold service
// - Fixed UpdateRecord bug (line 263): Changed r.PutRecord to r.UpdateRecord
// (UpdateRecord was incorrectly calling PutRecord, causing incorrect MST operations)
// - Removed 5 Prometheus metrics calls (openAndSigCheckDuration, calcDiffDuration,
// writeCarSliceDuration, repoOpsImported) as metrics are not used in this project
// - Added PutRecord method (lines 309-381) for creating records with explicit rkeys
// (like CreateRecord but with specified rkey instead of auto-generated TID)
// Based on streamplace/indigo implementation
package pds
import (
+53 -25
View File
@@ -27,6 +27,7 @@ type XRPCHandler struct {
publicURL string
blobStore BlobStore
broadcaster *EventBroadcaster
httpClient HTTPClient // For testing - allows injecting mock HTTP client
}
// BlobStore interface wraps the existing hold service storage operations
@@ -48,8 +49,8 @@ type BlobStore interface {
// 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 a presigned URL for uploading a specific part
GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (url 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
@@ -64,13 +65,22 @@ type PartInfo struct {
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) *XRPCHandler {
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,
}
}
@@ -78,8 +88,8 @@ func NewXRPCHandler(pds *HoldPDS, publicURL string, blobStore BlobStore, broadca
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, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
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 {
@@ -444,7 +454,7 @@ func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request)
}
// Validate DPoP + OAuth and check authorization
_, err := ValidateOwnerOrCrewAdmin(r, h.pds)
_, err := ValidateOwnerOrCrewAdmin(r, h.pds, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("unauthorized: %v", err), http.StatusForbidden)
return
@@ -733,16 +743,16 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
// Mode 3: Direct blob upload (ATProto-compliant)
// Receives raw bytes, computes CID, stores via distribution driver
// TODO: Authentication check
// Extract DID for ATProto blob storage (per-DID paths)
did := r.URL.Query().Get("did")
if did == "" {
// TODO: Extract from auth context when authentication is implemented
// For now, use hold's DID as fallback
did = h.pds.DID()
// 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 {
@@ -770,6 +780,14 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
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 {
@@ -816,6 +834,14 @@ func (h *XRPCHandler) handleMultipartOperation(w http.ResponseWriter, r *http.Re
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":
@@ -844,22 +870,14 @@ func (h *XRPCHandler) handleMultipartOperation(w http.ResponseWriter, r *http.Re
return
}
// Extract DID from query or header (for authorization)
did := r.URL.Query().Get("did")
if did == "" {
did = r.Header.Get("X-ATCR-DID")
}
url, err := h.blobStore.GetPartUploadURL(ctx, req.UploadID, req.PartNumber, did)
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(map[string]any{
"url": url,
})
json.NewEncoder(w).Encode(uploadInfo)
case "complete":
// Complete multipart upload
@@ -902,6 +920,7 @@ func (h *XRPCHandler) handleMultipartOperation(w http.ResponseWriter, r *http.Re
// 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)
@@ -921,6 +940,15 @@ func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
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:") {
@@ -1035,7 +1063,7 @@ func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request)
}
// Validate DPoP + OAuth token from Authorization and DPoP headers
user, err := ValidateDPoPRequest(r)
user, err := ValidateDPoPRequest(r, h.httpClient)
if err != nil {
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
return
+45 -20
View File
@@ -7,6 +7,18 @@ import (
"testing"
)
// addTestDPoPAuth adds DPoP authentication headers to a request for testing
func addTestDPoPAuth(t *testing.T, req *http.Request, did string) {
t.Helper()
dpopHelper, err := NewDPoPTestHelper(did, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
}
// ATCR-Specific Tests: Non-standard multipart upload extensions
//
// This file contains tests for ATCR's custom multipart upload extensions
@@ -28,15 +40,17 @@ func TestHandleUploadBlob_MultipartStart(t *testing.T) {
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
// Add DPoP authentication - owner has blob:write permission
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should return 200 OK with upload metadata
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
// Verify response contains uploadId and mode
result := assertJSONResponse(t, w, http.StatusOK)
if uploadID, ok := result["uploadId"].(string); !ok || uploadID == "" {
@@ -62,6 +76,7 @@ func TestHandleUploadBlob_MultipartStart_MissingDigest(t *testing.T) {
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
@@ -80,7 +95,7 @@ func TestHandleUploadBlob_MultipartPart(t *testing.T) {
uploadID := "test-upload-123"
partNumber := 1
did := "did:plc:testuser"
expectedDID := "did:plc:testowner123" // DID from authenticated user
body := map[string]any{
"action": "part",
@@ -88,30 +103,31 @@ func TestHandleUploadBlob_MultipartPart(t *testing.T) {
"partNumber": partNumber,
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob?did="+did, body)
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, expectedDID)
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should return 200 OK with presigned URL
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
// Verify response contains URL
result := assertJSONResponse(t, w, http.StatusOK)
if url, ok := result["url"].(string); !ok || url == "" {
t.Error("Expected url string in response")
}
// Verify blob store was called
// Verify blob store was called with authenticated user's DID
if len(blobStore.partURLCalls) != 1 {
t.Fatalf("Expected GetPartUploadURL to be called once")
}
call := blobStore.partURLCalls[0]
if call.uploadID != uploadID || call.partNumber != partNumber || call.did != did {
if call.uploadID != uploadID || call.partNumber != partNumber || call.did != expectedDID {
t.Errorf("Expected GetPartUploadURL(%s, %d, %s), got (%s, %d, %s)",
uploadID, partNumber, did, call.uploadID, call.partNumber, call.did)
uploadID, partNumber, expectedDID, call.uploadID, call.partNumber, call.did)
}
}
@@ -150,6 +166,7 @@ func TestHandleUploadBlob_MultipartPart_MissingParams(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", tt.body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
@@ -181,15 +198,16 @@ func TestHandleUploadBlob_MultipartComplete(t *testing.T) {
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should return 200 OK with completion status
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
// Verify response
result := assertJSONResponse(t, w, http.StatusOK)
if status, ok := result["status"].(string); !ok || status != "completed" {
@@ -237,6 +255,7 @@ func TestHandleUploadBlob_MultipartComplete_MissingParams(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", tt.body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
@@ -263,15 +282,16 @@ func TestHandleUploadBlob_MultipartAbort(t *testing.T) {
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should return 200 OK with abort status
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
// Verify response
result := assertJSONResponse(t, w, http.StatusOK)
if status, ok := result["status"].(string); !ok || status != "aborted" {
@@ -293,6 +313,7 @@ func TestHandleUploadBlob_MultipartAbort_MissingUploadID(t *testing.T) {
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
@@ -316,15 +337,16 @@ func TestHandleUploadBlob_BufferedPartUpload(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(data))
req.Header.Set("X-Upload-Id", uploadID)
req.Header.Set("X-Part-Number", partNumber)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should return 200 OK with ETag
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
// Verify response contains ETag
result := assertJSONResponse(t, w, http.StatusOK)
if etag, ok := result["etag"].(string); !ok || etag == "" {
@@ -347,11 +369,11 @@ func TestHandleUploadBlob_BufferedPartUpload_MissingHeaders(t *testing.T) {
handler, _, _ := setupTestXRPCHandlerWithBlobs(t)
tests := []struct {
name string
uploadID string
partNumber string
setUploadID bool
setPartNumber bool
name string
uploadID string
partNumber string
setUploadID bool
setPartNumber bool
}{
{
name: "missing both headers",
@@ -381,6 +403,7 @@ func TestHandleUploadBlob_BufferedPartUpload_MissingHeaders(t *testing.T) {
if tt.setPartNumber {
req.Header.Set("X-Part-Number", tt.partNumber)
}
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
@@ -399,6 +422,7 @@ func TestHandleUploadBlob_BufferedPartUpload_InvalidPartNumber(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("data")))
req.Header.Set("X-Upload-Id", "test-123")
req.Header.Set("X-Part-Number", "not-a-number")
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
@@ -417,6 +441,7 @@ func TestHandleUploadBlob_UnknownAction(t *testing.T) {
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.uploadBlob", body)
addTestDPoPAuth(t, req, "did:plc:testowner123")
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
+138 -41
View File
@@ -54,8 +54,11 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) {
t.Fatalf("Failed to bootstrap PDS: %v", err)
}
// Create XRPC handler
handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil)
// Create mock PDS client for DPoP validation
mockClient := &mockPDSClient{}
// Create XRPC handler with mock HTTP client
handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil, mockClient)
return handler, ctx
}
@@ -652,7 +655,8 @@ func TestHandleListRecords_InvalidLimit(t *testing.T) {
// TestHandleListRecords_EmptyCollection tests listing empty collection
func TestHandleListRecords_EmptyCollection(t *testing.T) {
pds, ctx := setupTestPDS(t) // Don't bootstrap - no records created yet
handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil)
mockClient := &mockPDSClient{}
handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil, mockClient)
// Initialize repo manually (setupTestPDS doesn't call Bootstrap, so no crew members)
err := pds.repomgr.InitNewActor(ctx, pds.uid, "", pds.did, "", "", "")
@@ -744,31 +748,37 @@ func TestHandleDeleteRecord(t *testing.T) {
}
req := makeXRPCPostRequest("/xrpc/com.atproto.repo.deleteRecord", body)
// Add DPoP authentication - owner has admin permission to delete crew
ownerDID := "did:plc:testowner123"
dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
w := httptest.NewRecorder()
// Note: This test will fail auth check since we're not providing DPoP tokens
// For now, we're testing the request parsing and response structure
// A real implementation would need proper auth mocking
handler.HandleDeleteRecord(w, req)
// We expect 403 Forbidden due to missing auth
// This tests that the endpoint is parsing JSON body correctly
if w.Code != http.StatusForbidden {
// If somehow auth passes (shouldn't in this test), verify response structure
if w.Code == http.StatusOK {
result := assertJSONResponse(t, w, http.StatusOK)
// Should return 200 OK with commit metadata
if w.Code != http.StatusOK {
t.Errorf("Expected status 200 OK, got %d", w.Code)
}
// Per spec, response should have commit object
if commit, ok := result["commit"].(map[string]any); !ok {
t.Error("Expected commit object in response")
} else {
if cid, ok := commit["cid"].(string); !ok || cid == "" {
t.Error("Expected cid in commit object")
}
if rev, ok := commit["rev"].(string); !ok || rev == "" {
t.Error("Expected rev in commit object")
}
}
result := assertJSONResponse(t, w, http.StatusOK)
// Per spec, response should have commit object
if commit, ok := result["commit"].(map[string]any); !ok {
t.Error("Expected commit object in response")
} else {
if cid, ok := commit["cid"].(string); !ok || cid == "" {
t.Error("Expected cid in commit object")
}
if rev, ok := commit["rev"].(string); !ok || rev == "" {
t.Error("Expected rev in commit object")
}
}
}
@@ -902,7 +912,8 @@ func TestHandleListRepos(t *testing.T) {
// Spec: https://docs.bsky.app/docs/api/com-atproto-sync-list-repos
func TestHandleListRepos_EmptyRepo(t *testing.T) {
pds, ctx := setupTestPDS(t) // Don't bootstrap
handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil)
mockClient := &mockPDSClient{}
handler := NewXRPCHandler(pds, "https://hold.example.com", nil, nil, mockClient)
// setupTestPDS creates the PDS/database but doesn't initialize the repo
// Check if implementation returns repos before initialization
@@ -1334,14 +1345,14 @@ type mockBlobStore struct {
partUploadError error
// Track calls
downloadCalls []string // Track digests requested for download
uploadCalls []string // Track digests requested for upload
uploadBlobCalls []uploadBlobCall // Track direct blob uploads
startCalls []string // Track digests for multipart start
partURLCalls []partURLCall
completeCalls []string
abortCalls []string
partUploadCalls []partUploadCall
downloadCalls []string // Track digests requested for download
uploadCalls []string // Track digests requested for upload
uploadBlobCalls []uploadBlobCall // Track direct blob uploads
startCalls []string // Track digests for multipart start
partURLCalls []partURLCall
completeCalls []string
abortCalls []string
partUploadCalls []partUploadCall
}
type uploadBlobCall struct {
@@ -1419,12 +1430,15 @@ func (m *mockBlobStore) StartMultipartUpload(ctx context.Context, digest string)
return "test-upload-id", "s3native", nil
}
func (m *mockBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (string, error) {
func (m *mockBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, did string) (*PartUploadInfo, error) {
m.partURLCalls = append(m.partURLCalls, partURLCall{uploadID, partNumber, did})
if m.partURLError != nil {
return "", m.partURLError
return nil, m.partURLError
}
return "https://s3.example.com/part/" + uploadID, nil
return &PartUploadInfo{
URL: "https://s3.example.com/part/" + uploadID,
Method: "PUT",
}, nil
}
func (m *mockBlobStore) CompleteMultipartUpload(ctx context.Context, uploadID string, parts []PartInfo) error {
@@ -1451,7 +1465,7 @@ func (m *mockBlobStore) HandleBufferedPartUpload(ctx context.Context, uploadID s
return "test-etag-" + uploadID, nil
}
// setupTestXRPCHandlerWithBlobs creates handler with mock blob store
// setupTestXRPCHandlerWithBlobs creates handler with mock blob store and mock PDS client
func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockBlobStore, context.Context) {
t.Helper()
@@ -1488,8 +1502,11 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockBlobStore,
// Create mock blob store
blobStore := newMockBlobStore()
// Create XRPC handler with mock blob store
handler := NewXRPCHandler(pds, "https://hold.example.com", blobStore, nil)
// Create mock PDS client for DPoP validation
mockClient := &mockPDSClient{}
// Create XRPC handler with mock blob store and mock HTTP client
handler := NewXRPCHandler(pds, "https://hold.example.com", blobStore, nil, mockClient)
return handler, blobStore, ctx
}
@@ -1507,6 +1524,17 @@ func TestHandleUploadBlob(t *testing.T) {
// Test standard single blob upload (POST with raw bytes)
req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(blobData))
req.Header.Set("Content-Type", "application/octet-stream")
// Add DPoP authentication - owner has admin permission for blob upload
ownerDID := "did:plc:testowner123"
dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
@@ -1559,13 +1587,24 @@ func TestHandleUploadBlob_EmptyBody(t *testing.T) {
// Empty blob should succeed (edge case)
req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte{}))
req.Header.Set("Content-Type", "application/octet-stream")
// Add DPoP authentication
ownerDID := "did:plc:testowner123"
dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should succeed with empty blob
// Should return 200 OK for empty blob (edge case)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
t.Errorf("Expected status 200 OK for empty blob, got %d", w.Code)
}
// Verify blob store was called with 0 bytes
@@ -1600,12 +1639,24 @@ func TestHandleUploadBlob_BlobStoreError(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader([]byte("test data")))
req.Header.Set("Content-Type", "application/octet-stream")
// Add DPoP authentication
ownerDID := "did:plc:testowner123"
dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
w := httptest.NewRecorder()
handler.HandleUploadBlob(w, req)
// Should get 500 Internal Server Error for blob store error
if w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500, got %d", w.Code)
t.Errorf("Expected status 500 for blob store error, got %d", w.Code)
}
}
@@ -1775,3 +1826,49 @@ func TestHandleGetBlob_BlobStoreError(t *testing.T) {
t.Errorf("Expected status 500, got %d", w.Code)
}
}
// TestHandleGetBlobCORSHeaders tests that CORS headers are set for blob downloads
// // Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
func TestHandleGetBlob_CORSHeaders(t *testing.T) {
handler, _, ctx := setupTestXRPCHandlerWithBlobs(t)
// Make hold public
_, err := handler.pds.UpdateCaptainRecord(ctx, true, false)
if err != nil {
t.Fatalf("Failed to update captain: %v", err)
}
holdDID := "did:web:hold.example.com"
cid := "bafyreib2rxk3rkhh5ylyxj3x3gathxt3s32qvwj2lf3qg4kmzr6b7teqke"
url := fmt.Sprintf("/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", holdDID, cid)
// Test GET request
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
// Wrap with CORS middleware
corsHandler := corsMiddleware(handler.HandleGetBlob)
corsHandler(w, req)
// Verify CORS headers are present
if origin := w.Header().Get("Access-Control-Allow-Origin"); origin != "*" {
t.Errorf("Expected Access-Control-Allow-Origin: *, got %s", origin)
}
// Test OPTIONS preflight
req2 := httptest.NewRequest(http.MethodOptions, url, nil)
w2 := httptest.NewRecorder()
corsHandler(w2, req2)
if w2.Code != http.StatusOK {
t.Errorf("Expected OPTIONS to return 200, got %d", w2.Code)
}
methods := w2.Header().Get("Access-Control-Allow-Methods")
if !strings.Contains(methods, "GET") || !strings.Contains(methods, "HEAD") {
t.Errorf("Expected Access-Control-Allow-Methods to include GET and HEAD, got %s", methods)
}
t.Logf("✓ CORS headers correctly set for blob downloads")
}
+15 -6
View File
@@ -22,14 +22,23 @@ type HoldPDSInterface interface {
type HoldService struct {
driver storagedriver.StorageDriver
config *Config
s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage)
bucket string // S3 bucket name
s3PathPrefix string // S3 path prefix (if any)
MultipartMgr *MultipartManager // Exported for access in route handlers
pds HoldPDSInterface // Embedded PDS for captain/crew records
authorizer auth.HoldAuthorizer // Authorizer for access control
s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage)
bucket string // S3 bucket name
s3PathPrefix string // S3 path prefix (if any)
MultipartMgr *MultipartManager // Exported for access in route handlers
pds HoldPDSInterface // Embedded PDS for captain/crew records
authorizer auth.HoldAuthorizer // Authorizer for access control
}
// PresignedURLOperation defines the type of presigned URL operation
type PresignedURLOperation string
const (
OperationGet PresignedURLOperation = "GET"
OperationHead PresignedURLOperation = "HEAD"
OperationPut PresignedURLOperation = "PUT"
)
// NewHoldService creates a new hold service
// holdPDS must be a *pds.HoldPDS but we use any to avoid import cycle
func NewHoldService(cfg *Config, holdPDS any) (*HoldService, error) {
+45 -10
View File
@@ -77,8 +77,12 @@ func (s *HoldService) getPresignedURL(ctx context.Context, operation PresignedUR
// Check if presigned URLs are disabled
if s.config.Server.DisablePresignedURLs {
log.Printf("Presigned URLs disabled, using proxy URL")
return s.getProxyURL(digest, did), nil
log.Printf("Presigned URLs disabled, using XRPC endpoint")
url := s.getProxyURL(digest, did, operation)
if url == "" {
return "", fmt.Errorf("XRPC proxy not supported for PUT operations - use multipart upload")
}
return url, nil
}
// Generate presigned URL if S3 client is available
@@ -122,19 +126,50 @@ func (s *HoldService) getPresignedURL(ctx context.Context, operation PresignedUR
url, err := req.Presign(15 * time.Minute)
if err != nil {
log.Printf("[getPresignedURL] Presign FAILED for %s: %v", operation, err)
log.Printf(" Falling back to proxy URL")
return s.getProxyURL(digest, did), nil
log.Printf(" Falling back to XRPC endpoint")
proxyURL := s.getProxyURL(digest, 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 proxy URL through this service
return s.getProxyURL(digest, did), nil
// Fallback: return XRPC endpoint through this service
proxyURL := s.getProxyURL(digest, did, operation)
if proxyURL == "" {
return "", fmt.Errorf("S3 client not available and XRPC proxy not supported for PUT operations")
}
return proxyURL, nil
}
// getProxyURL returns a proxy URL for blob operations (fallback when presigned URLs unavailable)
func (s *HoldService) getProxyURL(digest, did string) string {
// All operations use the same proxy endpoint
return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did)
// 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 (s *HoldService) getProxyURL(digest, did string, operation PresignedURLOperation) string {
// For read operations, use XRPC getBlob endpoint
if operation == OperationGet || operation == OperationHead {
// Generate hold DID from public URL
holdDID := s.getHoldDID()
return fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
s.config.Server.PublicURL, holdDID, digest)
}
// For PUT operations, proxy fallback is not supported with XRPC
// Clients should use multipart upload flow via com.atproto.repo.uploadBlob
return ""
}
// getHoldDID generates a did:web from the hold's public URL
func (s *HoldService) getHoldDID() string {
// Convert URL to did:web format
// https://hold01.atcr.io → did:web:hold01.atcr.io
url := s.config.Server.PublicURL
url = strings.TrimPrefix(url, "https://")
url = strings.TrimPrefix(url, "http://")
url = strings.Split(url, "/")[0] // Remove path
url = strings.Split(url, ":")[0] // Remove port
return fmt.Sprintf("did:web:%s", url)
}