diff --git a/cmd/hold/main.go b/cmd/hold/main.go index 7e7b378..0c4458a 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -82,8 +82,6 @@ func main() { http.NotFound(w, r) }) - mux.HandleFunc("/health", service.HealthHandler) - mux.HandleFunc("/register", service.HandleRegister) mux.HandleFunc("/presigned-url", service.HandlePresignedURL) mux.HandleFunc("/move", service.HandleMove) diff --git a/docs/XRPC_BLOB_MIGRATION.md b/docs/XRPC_BLOB_MIGRATION.md new file mode 100644 index 0000000..092f581 --- /dev/null +++ b/docs/XRPC_BLOB_MIGRATION.md @@ -0,0 +1,675 @@ +# XRPC Blob Upload Migration + +This document describes how to migrate from separate legacy multipart upload endpoints to a unified `com.atproto.repo.uploadBlob` endpoint that supports both standard single-blob uploads and OCI container layer multipart uploads. + +## Current State + +### Legacy HTTP Endpoints (cmd/hold/main.go) + +```go +// 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 part upload (when presigned URLs unavailable) +mux.HandleFunc("/multipart-parts/", func(w http.ResponseWriter, r *http.Request) { + // Parse URL: /multipart-parts/{uploadID}/{partNumber} + // ... + service.HandleMultipartPartUpload(w, r, uploadID, partNumber, did, service.MultipartMgr) +}) +``` + +### Existing XRPC Endpoint (pkg/hold/pds/xrpc.go) + +```go +// Current implementation - redirects to presigned URL +func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) { + digest := r.URL.Query().Get("digest") + uploadURL, err := h.blobStore.GetPresignedUploadURL(digest) + http.Redirect(w, r, uploadURL, http.StatusFound) +} +``` + +### Supporting Code + +**pkg/hold/multipart.go:** +- `MultipartManager` - Tracks upload sessions +- `MultipartSession` - State for each upload (parts, mode, etc.) +- Modes: `S3Native` (presigned URLs), `Buffered` (proxy uploads) + +**pkg/hold/blobstore_adapter.go:** +- `HoldServiceBlobStore` - Adapter wrapping HoldService for XRPC handlers +- Implements presigned URL generation +- Currently not used by XRPC handlers + +**pkg/hold/handlers.go:** +- `HandleStartMultipart()` - Starts upload, returns uploadID +- `HandleGetPartURL()` - Returns presigned URL for part +- `HandleCompleteMultipart()` - Finalizes upload, assembles parts +- `HandleAbortMultipart()` - Cancels upload +- `HandleMultipartPartUpload()` - Buffered part upload fallback + +## New Unified Design + +### Single Endpoint: `com.atproto.repo.uploadBlob` + +Content-Type discrimination determines operation: +- `application/octet-stream` → Standard blob upload (profile images, small media) +- `application/json` → Multipart operations (large OCI layers) + +### API Specification + +#### Standard Single Upload (ATProto Spec Compliant) + +``` +POST /xrpc/com.atproto.repo.uploadBlob +Content-Type: application/octet-stream + +[raw blob bytes] + +Response (200 OK): +{ + "blob": { + "$type": "blob", + "ref": { + "$link": "bafyreib..." // CID + }, + "mimeType": "application/octet-stream", + "size": 12345 + } +} +``` + +**Use case:** Profile images, small media (< 10MB), standard ATProto blobs + +#### Multipart Start (ATCR Extension) + +``` +POST /xrpc/com.atproto.repo.uploadBlob +Content-Type: application/json + +{ + "action": "start", + "digest": "sha256:abc123...", + "size": 1234567890 // Optional hint for storage allocation +} + +Response (200 OK): +{ + "uploadId": "upload-1634567890", + "expiresAt": "2025-10-16T12:00:00Z", + "mode": "s3-native" // or "buffered" +} +``` + +**Implementation:** +- Calls `service.StartMultipartUploadWithManager(ctx, digest, multipartMgr)` +- Returns uploadID and mode from MultipartSession + +#### Multipart Get Part URL (ATCR Extension) + +``` +POST /xrpc/com.atproto.repo.uploadBlob +Content-Type: application/json + +{ + "action": "part", + "uploadId": "upload-1634567890", + "partNumber": 1, + "digest": "sha256:abc123..." +} + +Response (200 OK): +{ + "url": "https://s3.amazonaws.com/bucket/...?X-Amz-...", + "expiresAt": "2025-10-16T12:15:00Z", + "method": "PUT" +} + +// OR for buffered mode: +{ + "url": "https://hold01.atcr.io/xrpc/com.atproto.repo.uploadBlob", + "method": "PUT", + "headers": { + "X-Upload-Id": "upload-1634567890", + "X-Part-Number": "1" + }, + "expiresAt": "2025-10-16T12:15:00Z" +} +``` + +**Implementation:** +- Retrieve session: `multipartMgr.GetSession(uploadID)` +- S3Native mode: Call `service.GetPartUploadURL(ctx, session, partNumber, did)` +- Buffered mode: Return self-referential URL with headers + +#### Multipart Upload Part (Buffered Mode) + +``` +PUT /xrpc/com.atproto.repo.uploadBlob +Content-Type: application/octet-stream +X-Upload-Id: upload-1634567890 +X-Part-Number: 1 + +[part data bytes] + +Response (200 OK): +{ + "etag": "abc123def456", + "partNumber": 1 +} +``` + +**Implementation:** +- Extract headers: `X-Upload-Id`, `X-Part-Number` +- Call `service.HandleMultipartPartUpload(w, r, uploadID, partNumber, did, multipartMgr)` +- Return ETag for completion + +#### Multipart Complete (ATCR Extension) + +``` +POST /xrpc/com.atproto.repo.uploadBlob +Content-Type: application/json + +{ + "action": "complete", + "uploadId": "upload-1634567890", + "digest": "sha256:abc123...", + "parts": [ + { "partNumber": 1, "etag": "abc123" }, + { "partNumber": 2, "etag": "def456" } + ] +} + +Response (200 OK): +{ + "status": "completed", + "blob": { + "$type": "blob", + "ref": { + "$link": "bafyreib..." // CID computed from digest + }, + "mimeType": "application/octet-stream", + "size": 1234567890 + } +} +``` + +**Implementation:** +- Retrieve session: `multipartMgr.GetSession(uploadID)` +- For S3Native: Record parts via `session.RecordS3Part()` +- Call `service.CompleteMultipartUploadWithManager(ctx, session, multipartMgr)` +- Convert digest to CID for response + +#### Multipart Abort (ATCR Extension) + +``` +POST /xrpc/com.atproto.repo.uploadBlob +Content-Type: application/json + +{ + "action": "abort", + "uploadId": "upload-1634567890", + "digest": "sha256:abc123..." +} + +Response (200 OK): +{ + "status": "aborted" +} +``` + +**Implementation:** +- Retrieve session: `multipartMgr.GetSession(uploadID)` +- Call `service.AbortMultipartUploadWithManager(ctx, session, multipartMgr)` + +## Implementation Strategy + +### Phase 1: Add Unified Handler (Keep Legacy Endpoints) + +**File:** `pkg/hold/pds/xrpc.go` + +```go +// HandleUploadBlob unified handler supporting both single and multipart uploads +func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodPut { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + contentType := r.Header.Get("Content-Type") + + // Buffered multipart part upload (PUT with headers) + if r.Method == http.MethodPut && r.Header.Get("X-Upload-Id") != "" { + h.handleBufferedPartUpload(w, r) + return + } + + // Multipart operations (JSON body) + if strings.Contains(contentType, "application/json") { + h.handleMultipartOperation(w, r) + return + } + + // Standard single blob upload (raw bytes) + h.handleSingleBlobUpload(w, r) +} + +func (h *XRPCHandler) handleMultipartOperation(w http.ResponseWriter, r *http.Request) { + var req struct { + Action string `json:"action"` + Digest string `json:"digest,omitempty"` + Size int64 `json:"size,omitempty"` + UploadID string `json:"uploadId,omitempty"` + PartNumber int `json:"partNumber,omitempty"` + Parts []struct { + PartNumber int `json:"partNumber"` + ETag string `json:"etag"` + } `json:"parts,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // TODO: Add authentication check + // user, err := ValidateDPoPRequest(r) + + ctx := r.Context() + + switch req.Action { + case "start": + h.handleMultipartStart(w, r, req.Digest, req.Size) + case "part": + h.handleMultipartPart(w, r, req.UploadID, req.PartNumber, req.Digest) + case "complete": + h.handleMultipartComplete(w, r, req.UploadID, req.Digest, req.Parts) + case "abort": + h.handleMultipartAbort(w, r, req.UploadID, req.Digest) + default: + http.Error(w, "invalid action", http.StatusBadRequest) + } +} + +func (h *XRPCHandler) handleMultipartStart(w http.ResponseWriter, r *http.Request, digest string, size int64) { + ctx := r.Context() + + // Use HoldService multipart manager + // Note: h.blobStore is HoldServiceBlobStore which wraps the service + uploadID, mode, err := h.blobStore.StartMultipart(ctx, digest, size) + if err != nil { + http.Error(w, fmt.Sprintf("failed to start upload: %v", err), http.StatusInternalServerError) + return + } + + response := map[string]any{ + "uploadId": uploadID, + "expiresAt": time.Now().Add(24 * time.Hour), + "mode": mode, // "s3-native" or "buffered" + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (h *XRPCHandler) handleMultipartPart(w http.ResponseWriter, r *http.Request, uploadID string, partNumber int, digest string) { + ctx := r.Context() + + // Get part upload URL (presigned S3 or buffered endpoint) + partURL, err := h.blobStore.GetPartUploadURL(ctx, uploadID, partNumber, digest) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get part URL: %v", err), http.StatusInternalServerError) + return + } + + response := map[string]any{ + "url": partURL, + "expiresAt": time.Now().Add(15 * time.Minute), + "method": "PUT", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (h *XRPCHandler) handleMultipartComplete(w http.ResponseWriter, r *http.Request, uploadID string, digest string, parts []struct{ PartNumber int; ETag string }) { + ctx := r.Context() + + // Convert parts format + completedParts := make([]hold.CompletedPart, len(parts)) + for i, p := range parts { + completedParts[i] = hold.CompletedPart{ + PartNumber: p.PartNumber, + ETag: p.ETag, + } + } + + // Complete upload + if err := h.blobStore.CompleteMultipart(ctx, uploadID, digest, completedParts); err != nil { + http.Error(w, fmt.Sprintf("failed to complete upload: %v", err), http.StatusInternalServerError) + return + } + + // Convert digest to CID for ATProto response format + cid, err := digestToCID(digest) + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate CID: %v", err), http.StatusInternalServerError) + return + } + + response := map[string]any{ + "status": "completed", + "blob": map[string]any{ + "$type": "blob", + "ref": map[string]any{ + "$link": cid.String(), + }, + "mimeType": "application/octet-stream", + // Size would need to be tracked in session + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (h *XRPCHandler) handleMultipartAbort(w http.ResponseWriter, r *http.Request, uploadID string, digest string) { + ctx := r.Context() + + if err := h.blobStore.AbortMultipart(ctx, uploadID, digest); err != nil { + http.Error(w, fmt.Sprintf("failed to abort upload: %v", err), http.StatusInternalServerError) + return + } + + response := map[string]any{ + "status": "aborted", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (h *XRPCHandler) handleBufferedPartUpload(w http.ResponseWriter, r *http.Request) { + uploadID := r.Header.Get("X-Upload-Id") + partNumberStr := r.Header.Get("X-Part-Number") + + partNumber, err := strconv.Atoi(partNumberStr) + if err != nil { + http.Error(w, "invalid part number", http.StatusBadRequest) + return + } + + // Stream part data to storage + etag, err := h.blobStore.UploadPart(r.Context(), uploadID, partNumber, r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to upload part: %v", err), http.StatusInternalServerError) + return + } + + response := map[string]any{ + "etag": etag, + "partNumber": partNumber, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (h *XRPCHandler) handleSingleBlobUpload(w http.ResponseWriter, r *http.Request) { + // Standard ATProto uploadBlob behavior + // Read blob data + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read blob", http.StatusInternalServerError) + return + } + + // Upload to storage (single operation) + cid, size, err := h.blobStore.UploadBlob(r.Context(), bytes.NewReader(data)) + if err != nil { + http.Error(w, fmt.Sprintf("failed to upload blob: %v", err), http.StatusInternalServerError) + return + } + + // Standard ATProto blob response format + response := map[string]any{ + "blob": map[string]any{ + "$type": "blob", + "ref": map[string]any{ + "$link": cid.String(), + }, + "mimeType": "application/octet-stream", + "size": size, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// digestToCID converts OCI digest (sha256:abc...) to ATProto CID +func digestToCID(digest string) (cid.Cid, error) { + // Implementation in pkg/hold/cid.go or similar + // Strip "sha256:" prefix, decode hex, construct CIDv1 with sha256 multihash + return cid.Undef, fmt.Errorf("not implemented") +} +``` + +### Phase 2: Extend HoldServiceBlobStore (pkg/hold/blobstore_adapter.go) + +The `HoldServiceBlobStore` currently wraps HoldService for presigned URLs. Extend it to support multipart operations: + +```go +// Add multipart methods to HoldServiceBlobStore + +func (h *HoldServiceBlobStore) StartMultipart(ctx context.Context, digest string, size int64) (uploadID string, mode string, err error) { + uploadID, uploadMode, err := h.service.StartMultipartUploadWithManager(ctx, digest, h.service.MultipartMgr) + if err != nil { + return "", "", err + } + + modeStr := "s3-native" + if uploadMode == hold.Buffered { + modeStr = "buffered" + } + + return uploadID, modeStr, nil +} + +func (h *HoldServiceBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, digest string) (string, error) { + session, err := h.service.MultipartMgr.GetSession(uploadID) + if err != nil { + return "", err + } + + // For S3Native: return presigned URL + // For Buffered: return self-referential URL with upload instructions + if session.Mode == hold.S3Native { + return h.service.GetPartUploadURL(ctx, session, partNumber, h.holdDID) + } + + // Buffered mode: client will PUT to uploadBlob with headers + return fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", h.publicURL), nil +} + +func (h *HoldServiceBlobStore) UploadPart(ctx context.Context, uploadID string, partNumber int, data io.Reader) (string, error) { + // Buffered part upload - streams data to storage + // Used when client PUTs to uploadBlob with X-Upload-Id header + session, err := h.service.MultipartMgr.GetSession(uploadID) + if err != nil { + return "", err + } + + // Stream to storage, return ETag + // This wraps HandleMultipartPartUpload logic + etag, err := h.service.UploadPartBuffered(ctx, session, partNumber, data) + return etag, err +} + +func (h *HoldServiceBlobStore) CompleteMultipart(ctx context.Context, uploadID string, digest string, parts []hold.CompletedPart) error { + session, err := h.service.MultipartMgr.GetSession(uploadID) + if err != nil { + return err + } + + // For S3Native: record parts ETags + if session.Mode == hold.S3Native { + for _, p := range parts { + session.RecordS3Part(p.PartNumber, p.ETag, 0) + } + } + + return h.service.CompleteMultipartUploadWithManager(ctx, session, h.service.MultipartMgr) +} + +func (h *HoldServiceBlobStore) AbortMultipart(ctx context.Context, uploadID string, digest string) error { + session, err := h.service.MultipartMgr.GetSession(uploadID) + if err != nil { + return err + } + + return h.service.AbortMultipartUploadWithManager(ctx, session, h.service.MultipartMgr) +} + +func (h *HoldServiceBlobStore) UploadBlob(ctx context.Context, data io.Reader) (cid.Cid, int64, error) { + // Single blob upload for standard ATProto use case + // Compute digest, store via service driver + // Return CID and size + // Implementation TBD + return cid.Undef, 0, fmt.Errorf("not implemented") +} +``` + +### Phase 3: Update AppView Client (pkg/appview/storage/) + +Create new XRPC client or update ProxyBlobStore to use unified endpoint: + +```go +// In ProxyBlobStore or new XRPCBlobStore + +func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string) (string, error) { + reqBody := map[string]any{ + "action": "start", + "digest": digest, + } + + body, _ := json.Marshal(reqBody) + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint) + req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + // ... parse response, return uploadID +} + +func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) { + reqBody := map[string]any{ + "action": "part", + "uploadId": uploadID, + "partNumber": partNumber, + "digest": digest, + } + + body, _ := json.Marshal(reqBody) + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint) + req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + // ... parse response, return presigned URL +} + +// Similar for complete, abort +``` + +### Phase 4: Testing Period + +**During transition:** +- Both legacy HTTP endpoints AND new XRPC endpoint active +- AppView can use either based on configuration/feature flag +- New deployments use XRPC +- Old deployments continue with legacy + +**Detection logic:** +```go +func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore { + // Try XRPC first (check for /.well-known/did.json) + if supportsXRPC(storageEndpoint) { + return NewXRPCBlobStore(storageEndpoint, ...) + } + // Fallback to legacy + return NewProxyBlobStore(storageEndpoint, ...) +} +``` + +### Phase 5: Remove Legacy Endpoints + +Once all holds migrated and tested: + +**cmd/hold/main.go - Remove:** +```go +// DELETE these lines +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) +mux.HandleFunc("/multipart-parts/", ...) +``` + +**pkg/hold/handlers.go - Mark as deprecated:** +```go +// Keep methods for now (used by service internals) +// But remove HTTP handler wrappers +``` + +## Key Design Decisions + +1. **Content-Type discrimination**: Natural way to distinguish single vs multipart uploads +2. **JSON bodies for multipart**: Follows XRPC conventions (like putRecord, deleteRecord) +3. **Preserve standard uploadBlob**: Raw bytes still work for profile images, small media +4. **Reuse existing code**: HoldService multipart logic unchanged, just new HTTP layer +5. **Backward compatibility**: Both endpoints active during transition +6. **Action-based routing**: Clear, extensible JSON structure + +## Benefits + +- ✅ Single endpoint for all blob operations +- ✅ Standard ATProto uploadBlob preserved +- ✅ XRPC-like JSON request/response +- ✅ Reuses existing multipart.go logic +- ✅ Gradual migration path +- ✅ Less endpoints to maintain +- ✅ Cleaner AppView client code + +## Testing Checklist + +- [ ] Single blob upload (< 10MB, raw bytes) +- [ ] Multipart start → part → complete flow +- [ ] S3Native mode (presigned URLs) +- [ ] Buffered mode (proxy uploads) +- [ ] Multipart abort +- [ ] Large blob upload (> 5GB, many parts) +- [ ] Concurrent uploads +- [ ] Upload resume after network failure +- [ ] Legacy endpoint backward compatibility +- [ ] AppView XRPC client integration +- [ ] Performance comparison (XRPC vs legacy) + +## Migration Timeline + +1. **Week 1**: Implement unified uploadBlob handler (Phase 1-2) +2. **Week 2**: Update AppView client, feature flag (Phase 3) +3. **Week 3**: Deploy to dev/staging, test both paths (Phase 4) +4. **Week 4**: Roll out to production (gradual) +5. **Week 5-6**: Monitor, verify all holds migrated +6. **Week 7**: Remove legacy endpoints (Phase 5) + +## References + +- ATProto uploadBlob spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob +- XRPC conventions: https://atproto.com/specs/xrpc +- Existing multipart implementation: pkg/hold/multipart.go +- Blob store adapter: pkg/hold/blobstore_adapter.go diff --git a/pkg/hold/handlers.go b/pkg/hold/handlers.go index dda4a28..1990359 100644 --- a/pkg/hold/handlers.go +++ b/pkg/hold/handlers.go @@ -8,8 +8,6 @@ import ( "log" "net/http" "time" - - "atcr.io/pkg/atproto" ) // PresignedURLOperation defines the type of presigned URL operation @@ -496,92 +494,3 @@ func (s *HoldService) HandleAbortMultipart(w http.ResponseWriter, r *http.Reques "status": "aborted", }) } - -// RegisterRequest represents a request to register this hold in a user's PDS -type RegisterRequest struct { - DID string `json:"did"` - AccessToken string `json:"access_token"` - PDSEndpoint string `json:"pds_endpoint"` -} - -// RegisterResponse contains the registration result -type RegisterResponse struct { - HoldURI string `json:"hold_uri"` - CrewURI string `json:"crew_uri"` - Message string `json:"message"` -} - -// HandleRegister registers this hold service in a user's PDS (manual endpoint) -func (s *HoldService) HandleRegister(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - var req RegisterRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) - return - } - - // Validate required fields - if req.DID == "" || req.AccessToken == "" || req.PDSEndpoint == "" { - http.Error(w, "missing required fields: did, access_token, pds_endpoint", http.StatusBadRequest) - return - } - - // Get public URL from config - publicURL := s.config.Server.PublicURL - if publicURL == "" { - // Fallback to constructing URL from request - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - publicURL = fmt.Sprintf("%s://%s", scheme, r.Host) - } - - // Derive hold name from URL - holdName, err := extractHostname(publicURL) - if err != nil { - http.Error(w, fmt.Sprintf("failed to extract hostname: %v", err), http.StatusBadRequest) - return - } - - ctx := r.Context() - - // Create ATProto client with user's credentials - client := atproto.NewClient(req.PDSEndpoint, req.DID, req.AccessToken) - - // Create HoldRecord - holdRecord := atproto.NewHoldRecord(publicURL, req.DID, s.config.Server.Public) - - holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord) - if err != nil { - http.Error(w, fmt.Sprintf("failed to create hold record: %v", err), http.StatusInternalServerError) - return - } - - log.Printf("Created hold record: %s", holdResult.URI) - - // Create HoldCrewRecord for the owner - crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, req.DID, "owner") - - crewRKey := fmt.Sprintf("%s-%s", holdName, req.DID) - crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord) - if err != nil { - http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError) - return - } - - log.Printf("Created crew record: %s", crewResult.URI) - - resp := RegisterResponse{ - HoldURI: holdResult.URI, - CrewURI: crewResult.URI, - Message: fmt.Sprintf("Successfully registered hold service. Storage endpoint: %s", publicURL), - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} diff --git a/pkg/hold/patterns.go b/pkg/hold/patterns.go deleted file mode 100644 index 6b39d84..0000000 --- a/pkg/hold/patterns.go +++ /dev/null @@ -1,40 +0,0 @@ -package hold - -import ( - "regexp" - "strings" -) - -// matchPattern checks if a handle matches a pattern -// Supports wildcards: "*" (all), "*.domain.com" (suffix), "prefix.*" (prefix), "*.mid.*" (contains) -func matchPattern(pattern, handle string) bool { - if pattern == "*" { - // Wildcard matches all - return true - } - - // Convert glob to regex and match - regex := globToRegex(pattern) - matched, err := regexp.MatchString(regex, handle) - if err != nil { - // Log error but fail closed (don't grant access on regex error) - return false - } - return matched -} - -// globToRegex converts a glob pattern to a regex pattern -// Examples: -// - "*.example.com" → "^.*\.example\.com$" -// - "subdomain.*" → "^subdomain\..*$" -// - "*.bsky.*" → "^.*\.bsky\..*$" -func globToRegex(pattern string) string { - // Escape special regex characters (except *) - escaped := regexp.QuoteMeta(pattern) - - // Replace escaped \* with .* - regex := strings.ReplaceAll(escaped, "\\*", ".*") - - // Anchor to start and end - return "^" + regex + "$" -} diff --git a/pkg/hold/service.go b/pkg/hold/service.go index 4ac1fe9..dff3665 100644 --- a/pkg/hold/service.go +++ b/pkg/hold/service.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "net/http" "net/url" "atcr.io/pkg/auth" @@ -96,12 +95,6 @@ func (s *HoldService) isAuthorizedWrite(did string) bool { return allowed } -// HealthHandler handles health check requests -func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"ok"}`)) -} - // extractHostname extracts the hostname from a URL func extractHostname(urlStr string) (string, error) { u, err := url.Parse(urlStr)