Merge branch 'presigned-urls'

This commit is contained in:
Evan Jarrett
2025-10-12 09:12:44 -05:00
24 changed files with 4159 additions and 1671 deletions
@@ -14,10 +14,10 @@ import (
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/reference"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/storage"
)
// Global refresher instance (set by main.go)
@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
@@ -16,17 +15,11 @@ import (
)
const (
// minPartSize is S3's minimum part size for multipart uploads
// Parts must be at least 5MB (except the last part)
minPartSize = 5 * 1024 * 1024 // 5MB
// maxChunkSize is the maximum buffer size before flushing to hold service
// Matches S3's minimum multipart upload size
maxChunkSize = 5 * 1024 * 1024 // 5MB
)
// CompletedPart represents a completed multipart upload part
type CompletedPart struct {
PartNumber int `json:"part_number"`
ETag string `json:"etag"`
}
// Global upload tracking (shared across all ProxyBlobStore instances)
// This is necessary because distribution creates new repository/blob store instances per request
var (
@@ -66,8 +59,13 @@ func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, re
// Stat returns the descriptor for a blob
func (p *ProxyBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) {
// Quick HEAD request to hold service to check if blob exists
url := fmt.Sprintf("%s/blobs/%s?did=%s", p.storageEndpoint, dgst.String(), p.did)
// Get presigned HEAD URL
url, err := p.getHeadURL(ctx, dgst)
if err != nil {
return distribution.Descriptor{}, distribution.ErrBlobUnknown
}
// Make HEAD request to presigned URL
req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil)
if err != nil {
return distribution.Descriptor{}, distribution.ErrBlobUnknown
@@ -149,26 +147,33 @@ func (p *ProxyBlobStore) Put(ctx context.Context, mediaType string, content []by
// Get upload URL
url, err := p.getUploadURL(ctx, dgst, int64(len(content)))
if err != nil {
fmt.Printf("[proxy_blob_store/Put] Failed to get upload URL: digest=%s, error=%v\n", dgst, 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)
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 {
return distribution.Descriptor{}, fmt.Errorf("upload failed with status %d", resp.StatusCode)
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{
Digest: dgst,
Size: int64(len(content)),
@@ -184,7 +189,19 @@ func (p *ProxyBlobStore) Delete(ctx context.Context, dgst digest.Digest) error {
// ServeBlob serves a blob via HTTP redirect
func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
// Get presigned download URL
// For HEAD requests, redirect to presigned HEAD URL
if r.Method == http.MethodHead {
url, err := p.getHeadURL(ctx, dgst)
if err != nil {
return err
}
// Redirect to presigned HEAD URL
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
return nil
}
// For GET requests, redirect to presigned URL
url, err := p.getDownloadURL(ctx, dgst)
if err != nil {
return err
@@ -195,7 +212,7 @@ func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r
return nil
}
// Create returns a blob writer for uploading
// Create returns a blob writer for uploading using multipart upload
func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) {
// Parse options
var opts distribution.CreateOptions
@@ -205,10 +222,11 @@ func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.Blo
}
}
// Use temp digest for upload location
// Generate unique writer ID
writerID := fmt.Sprintf("upload-%d", time.Now().UnixNano())
tempPath := fmt.Sprintf("uploads/temp-%s", writerID)
tempDigest := digest.Digest(tempPath)
// Use temp digest for upload location (will be moved to final digest on commit)
tempDigest := fmt.Sprintf("uploads/temp-%s", writerID)
// Start multipart upload via hold service
uploadID, err := p.startMultipartUpload(ctx, tempDigest)
@@ -216,7 +234,7 @@ func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.Blo
return nil, fmt.Errorf("failed to start multipart upload: %w", err)
}
fmt.Printf("DEBUG [proxy_blob_store/Create]: Started multipart upload: id=%s, uploadID=%s\n", writerID, uploadID)
fmt.Printf(" Started multipart upload: uploadID=%s\n", uploadID)
writer := &ProxyBlobWriter{
store: p,
@@ -224,13 +242,12 @@ func (p *ProxyBlobStore) Create(ctx context.Context, options ...distribution.Blo
uploadID: uploadID,
parts: make([]CompletedPart, 0),
partNumber: 1,
buffer: bytes.NewBuffer(make([]byte, 0, minPartSize)),
buffer: bytes.NewBuffer(make([]byte, 0, maxChunkSize)), // 5MB buffer
id: writerID,
startedAt: time.Now(),
tempDigest: tempDigest,
}
// Store in global map for Resume()
// Store in global uploads map for resume support
globalUploadsMu.Lock()
globalUploads[writer.id] = writer
globalUploadsMu.Unlock()
@@ -249,14 +266,21 @@ func (p *ProxyBlobStore) Resume(ctx context.Context, id string) (distribution.Bl
return nil, distribution.ErrBlobUploadUnknown
}
// Just return the writer - parts are buffered and flushed on demand
return writer, nil
}
// getDownloadURL requests a presigned download URL from the storage service
func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) {
// 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{
"did": p.did,
"digest": dgst.String(),
"operation": operation,
"did": p.did,
"digest": dgst,
}
// Only include size for PUT operations
if size > 0 {
reqBody["size"] = size
}
body, err := json.Marshal(reqBody)
@@ -264,7 +288,7 @@ func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest)
return "", err
}
url := fmt.Sprintf("%s/get-presigned-url", p.storageEndpoint)
url := fmt.Sprintf("%s/presigned-url", p.storageEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", err
@@ -278,7 +302,7 @@ func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get download URL: status %d", resp.StatusCode)
return "", fmt.Errorf("failed to get presigned URL: status %d", resp.StatusCode)
}
var result struct {
@@ -291,14 +315,31 @@ func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest)
return result.URL, nil
}
// getDownloadURL requests a presigned download URL from the storage service
func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) (string, error) {
return p.getPresignedURL(ctx, "GET", dgst.String(), 0)
}
// getHeadURL requests a presigned HEAD URL from the storage service
func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) {
return p.getPresignedURL(ctx, "HEAD", dgst.String(), 0)
}
// getUploadURL requests a presigned upload URL from the storage service
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
}
// startMultipartUpload initiates a multipart upload via hold service
func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string) (string, error) {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
"size": size,
"digest": digest,
}
body, err := json.Marshal(reqBody)
@@ -306,8 +347,7 @@ func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, s
return "", err
}
url := fmt.Sprintf("%s/put-presigned-url", p.storageEndpoint)
fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: Calling %s\n", url)
url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return "", err
@@ -321,7 +361,50 @@ func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, s
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to get upload URL: status %d", resp.StatusCode)
bodyBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("start multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
UploadID string `json:"upload_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
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) {
reqBody := map[string]any{
"did": p.did,
"digest": digest,
"upload_id": uploadID,
"part_number": partNumber,
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/part-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 {
bodyBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("get part URL failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
@@ -331,11 +414,85 @@ func (p *ProxyBlobStore) getUploadURL(ctx context.Context, dgst digest.Digest, s
return "", err
}
fmt.Printf("DEBUG [proxy_blob_store/getUploadURL]: Got presigned URL=%s\n", result.URL)
return result.URL, nil
}
// ProxyBlobWriter implements distribution.BlobWriter for proxy uploads
// completeMultipartUpload completes a multipart upload via hold service
func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error {
reqBody := map[string]any{
"did": p.did,
"digest": digest,
"upload_id": uploadID,
"parts": parts,
}
body, err := json.Marshal(reqBody)
if err != nil {
return err
}
url := fmt.Sprintf("%s/complete-multipart", 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 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("complete multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
// abortMultipartUpload aborts a multipart upload via hold service
func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, digest, uploadID string) error {
reqBody := map[string]any{
"did": p.did,
"digest": digest,
"upload_id": uploadID,
}
body, err := json.Marshal(reqBody)
if err != nil {
return err
}
url := fmt.Sprintf("%s/abort-multipart", 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 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("abort multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
// CompletedPart represents an uploaded part with its ETag
type CompletedPart struct {
PartNumber int `json:"part_number"`
ETag string `json:"etag"`
}
// ProxyBlobWriter implements distribution.BlobWriter for proxy uploads using multipart upload
type ProxyBlobWriter struct {
store *ProxyBlobStore
options distribution.CreateOptions
@@ -345,10 +502,9 @@ type ProxyBlobWriter struct {
buffer *bytes.Buffer // Buffer for current part
size int64 // Total bytes written
closed bool
id string // Distribution's upload ID (for state)
id string // Distribution's upload ID (for state)
startedAt time.Time
finalDigest string // Set on Commit
tempDigest digest.Digest // Temp location digest
finalDigest string // Set on Commit
}
// ID returns the upload ID
@@ -362,7 +518,7 @@ func (w *ProxyBlobWriter) StartedAt() time.Time {
}
// Write writes data to the upload
// Buffers data and flushes parts when buffer reaches minPartSize
// Buffers data and flushes when buffer reaches 5MB
func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
if w.closed {
return 0, fmt.Errorf("writer closed")
@@ -371,17 +527,17 @@ func (w *ProxyBlobWriter) Write(p []byte) (int, error) {
n, err := w.buffer.Write(p)
w.size += int64(n)
// Flush if buffer reaches minimum part size (5MB)
if w.buffer.Len() >= minPartSize {
// Flush if buffer reaches 5MB (S3 minimum part size)
if w.buffer.Len() >= maxChunkSize {
if err := w.flushPart(); err != nil {
return n, fmt.Errorf("failed to flush part: %w", err)
return n, err
}
}
return n, err
}
// flushPart uploads the current buffer as a multipart upload part
// flushPart uploads the current buffer as a part
func (w *ProxyBlobWriter) flushPart() error {
if w.buffer.Len() == 0 {
return nil
@@ -391,7 +547,8 @@ func (w *ProxyBlobWriter) flushPart() error {
defer cancel()
// Get presigned URL for this part
url, err := w.store.getPartPresignedURL(ctx, w.tempDigest, w.uploadID, w.partNumber)
tempDigest := fmt.Sprintf("uploads/temp-%s", w.id)
url, err := w.store.getPartPresignedURL(ctx, tempDigest, w.uploadID, w.partNumber)
if err != nil {
return fmt.Errorf("failed to get part presigned URL: %w", err)
}
@@ -403,11 +560,9 @@ func (w *ProxyBlobWriter) flushPart() error {
}
req.Header.Set("Content-Type", "application/octet-stream")
fmt.Printf("DEBUG [proxy_blob_store/flushPart]: Uploading part %d, size=%d bytes\n", w.partNumber, w.buffer.Len())
resp, err := w.store.httpClient.Do(req)
if err != nil {
return fmt.Errorf("part upload failed: %w", err)
return err
}
defer resp.Body.Close()
@@ -422,15 +577,12 @@ func (w *ProxyBlobWriter) flushPart() error {
return fmt.Errorf("no ETag in response")
}
// Remove quotes from ETag if present (S3 sometimes adds them)
etag = strings.Trim(etag, "\"")
w.parts = append(w.parts, CompletedPart{
PartNumber: w.partNumber,
ETag: etag,
})
fmt.Printf("DEBUG [proxy_blob_store/flushPart]: Part %d uploaded successfully, ETag=%s\n", w.partNumber, etag)
fmt.Printf("[flushPart] Part %d uploaded successfully: ETag=%s\n", w.partNumber, etag)
// Reset buffer and increment part number
w.buffer.Reset()
@@ -474,33 +626,41 @@ func (w *ProxyBlobWriter) Size() int64 {
return w.size
}
// Commit finalizes the upload
// Commit finalizes the upload by completing multipart upload and moving to final location
func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) {
if w.closed {
return distribution.Descriptor{}, fmt.Errorf("writer closed")
}
w.closed = true
// Flush any remaining buffered data as the final part
// Remove from global uploads map
globalUploadsMu.Lock()
delete(globalUploads, w.id)
globalUploadsMu.Unlock()
// Flush any remaining buffered data
if w.buffer.Len() > 0 {
fmt.Printf("[Commit] Flushing final buffer: %d bytes\n", w.buffer.Len())
if err := w.flushPart(); err != nil {
// Try to abort multipart on error
w.store.abortMultipartUpload(ctx, w.tempDigest, w.uploadID)
tempDigest := fmt.Sprintf("uploads/temp-%s", w.id)
w.store.abortMultipartUpload(ctx, tempDigest, w.uploadID)
return distribution.Descriptor{}, fmt.Errorf("failed to flush final part: %w", err)
}
}
// Complete multipart upload at temp location
if err := w.store.completeMultipartUpload(ctx, w.tempDigest, w.uploadID, w.parts); err != nil {
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)
}
fmt.Printf("DEBUG [proxy_blob_store/Commit]: Completed multipart upload with %d parts, total size=%d\n", len(w.parts), w.size)
// 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)
@@ -517,15 +677,10 @@ func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descript
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return distribution.Descriptor{}, fmt.Errorf("move failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
return distribution.Descriptor{}, fmt.Errorf("move blob failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
// Remove from global map
globalUploadsMu.Lock()
delete(globalUploads, w.id)
globalUploadsMu.Unlock()
fmt.Printf("DEBUG [proxy_blob_store/Commit]: Successfully committed: digest=%s, size=%d\n", desc.Digest, w.size)
fmt.Printf("[Commit] Upload completed successfully: digest=%s, size=%d, parts=%d\n", desc.Digest, w.size, len(w.parts))
return distribution.Descriptor{
Digest: desc.Digest,
@@ -534,153 +689,35 @@ func (w *ProxyBlobWriter) Commit(ctx context.Context, desc distribution.Descript
}, nil
}
// Cancel cancels the upload
// Cancel cancels the upload by aborting the multipart upload
func (w *ProxyBlobWriter) Cancel(ctx context.Context) error {
w.closed = true
fmt.Printf("[Cancel] Cancelling upload: id=%s\n", w.id)
// Remove from global uploads map
globalUploadsMu.Lock()
delete(globalUploads, w.id)
globalUploadsMu.Unlock()
// Abort multipart upload on S3
if err := w.store.abortMultipartUpload(ctx, w.tempDigest, w.uploadID); err != nil {
fmt.Printf("DEBUG [proxy_blob_store/Cancel]: Failed to abort multipart upload: %v\n", err)
// Continue anyway - we still want to clean up
// Abort multipart upload
tempDigest := fmt.Sprintf("uploads/temp-%s", w.id)
if err := w.store.abortMultipartUpload(ctx, tempDigest, w.uploadID); err != nil {
fmt.Printf("⚠️ [Cancel] Failed to abort multipart upload: %v\n", err)
// Continue anyway - we want to mark upload as cancelled
}
fmt.Printf("DEBUG [proxy_blob_store/Cancel]: Cancelled upload: id=%s, uploadID=%s\n", w.id, w.uploadID)
fmt.Printf("[Cancel] Upload cancelled: id=%s\n", w.id)
return nil
}
// Close closes the writer
// Does nothing - actual completion happens in Commit() or Cancel()
// Parts are flushed on demand, so this is a no-op
func (w *ProxyBlobWriter) Close() error {
// Don't set w.closed = true - allow resuming for next PATCH
return nil
}
// startMultipartUpload initiates a multipart upload via hold service
func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, dgst digest.Digest) (string, error) {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/start-multipart", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
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 start multipart upload: status %d", resp.StatusCode)
}
var result struct {
UploadID string `json:"upload_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.UploadID, nil
}
// getPartPresignedURL gets a presigned URL for uploading a specific part
func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, dgst digest.Digest, uploadID string, partNumber int) (string, error) {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
"upload_id": uploadID,
"part_number": partNumber,
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/part-presigned-url", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
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 part 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
}
// completeMultipartUpload completes a multipart upload
func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, dgst digest.Digest, uploadID string, parts []CompletedPart) error {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
"upload_id": uploadID,
"parts": parts,
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/complete-multipart", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
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 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("complete multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
// abortMultipartUpload aborts a multipart upload
func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, dgst digest.Digest, uploadID string) error {
reqBody := map[string]any{
"did": p.did,
"digest": dgst.String(),
"upload_id": uploadID,
}
body, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/abort-multipart", p.storageEndpoint)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
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 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("abort multipart failed: status %d, body: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
// readSeekCloser wraps an io.ReadCloser to implement ReadSeekCloser
type readSeekCloser struct {
io.ReadCloser
+21
View File
@@ -3,6 +3,7 @@ package atproto
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -343,6 +344,26 @@ func (c *Client) GetBlob(ctx context.Context, cid string) ([]byte, error) {
return nil, fmt.Errorf("failed to read blob data: %w", err)
}
// Check if PDS returned JSON-wrapped blob (Bluesky implementation)
// PDS may wrap blobs as JSON-encoded base64 strings
// Detection: Check if content starts with a quote (indicating JSON string)
if len(data) > 0 && data[0] == '"' {
// Blob is JSON-encoded - decode it
var base64Str string
if err := json.Unmarshal(data, &base64Str); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON-wrapped blob: %w", err)
}
// Base64-decode the blob content
decoded, err := base64.StdEncoding.DecodeString(base64Str)
if err != nil {
return nil, fmt.Errorf("failed to base64-decode blob: %w", err)
}
return decoded, nil
}
// Raw blob response (expected ATProto behavior)
return data, nil
}
+7
View File
@@ -5,6 +5,13 @@ import (
"strings"
)
// AccessEntry represents access permissions for a resource
type AccessEntry struct {
Type string `json:"type"` // "repository"
Name string `json:"name,omitempty"` // e.g., "alice/myapp"
Actions []string `json:"actions,omitempty"` // e.g., ["pull", "push"]
}
// ParseScope parses Docker registry scope strings into AccessEntry structures
// Scope format: "repository:alice/myapp:pull,push"
// Multiple scopes can be provided
-8
View File
@@ -1,8 +0,0 @@
package auth
// AccessEntry represents access permissions for a resource
type AccessEntry struct {
Type string `json:"type"` // "repository"
Name string `json:"name,omitempty"` // e.g., "alice/myapp"
Actions []string `json:"actions,omitempty"` // e.g., ["pull", "push"]
}
+131
View File
@@ -0,0 +1,131 @@
package hold
import (
"context"
"encoding/json"
"fmt"
"log"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// isAuthorizedRead checks if a DID can read from this hold
// Authorization:
// - Public hold: allow anonymous (empty DID) or any authenticated user
// - Private hold: require authentication (any user with sailor.profile)
func (s *HoldService) isAuthorizedRead(did string) bool {
// Check hold public flag
isPublic, err := s.isHoldPublic()
if err != nil {
log.Printf("ERROR: Failed to check hold public flag: %v", err)
// Fail secure - deny access on error
return false
}
if isPublic {
// Public hold - allow anyone (even anonymous)
return true
}
// Private hold - require authentication
// Any authenticated user with sailor.profile can read
if did == "" {
// Anonymous user trying to access private hold
return false
}
// For MVP: assume DID presence means they have sailor.profile
// Future: could query PDS to verify sailor.profile exists
return true
}
// isAuthorizedWrite checks if a DID can write to this hold
// Authorization: must be hold owner OR crew member
func (s *HoldService) isAuthorizedWrite(did string) bool {
if did == "" {
// Anonymous writes not allowed
return false
}
// Check if DID is the hold owner
ownerDID := s.config.Registration.OwnerDID
if ownerDID == "" {
log.Printf("ERROR: Hold owner DID not configured")
return false
}
if did == ownerDID {
// Owner always has write access
return true
}
// Check if DID is a crew member
isCrew, err := s.isCrewMember(did)
if err != nil {
log.Printf("ERROR: Failed to check crew membership: %v", err)
return false
}
return isCrew
}
// isHoldPublic checks if this hold allows public (anonymous) reads
func (s *HoldService) isHoldPublic() (bool, error) {
// Use cached config value for now
// Future: could query PDS for hold record to get live value
return s.config.Server.Public, nil
}
// isCrewMember checks if a DID is a crew member of this hold
func (s *HoldService) isCrewMember(did string) (bool, error) {
ownerDID := s.config.Registration.OwnerDID
if ownerDID == "" {
return false, fmt.Errorf("hold owner DID not configured")
}
ctx := context.Background()
// Resolve owner's PDS endpoint using indigo
directory := identity.DefaultDirectory()
ownerDIDParsed, err := syntax.ParseDID(ownerDID)
if err != nil {
return false, fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, ownerDIDParsed)
if err != nil {
return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return false, fmt.Errorf("no PDS endpoint found for owner")
}
// Create unauthenticated client to read public records
client := atproto.NewClient(pdsEndpoint, ownerDID, "")
// List crew records for this hold
// Crew records are public, so we can read them without auth
records, err := client.ListRecords(ctx, atproto.HoldCrewCollection, 100)
if err != nil {
return false, fmt.Errorf("failed to list crew records: %w", err)
}
// Check if DID is in crew list
for _, record := range records {
var crewRecord atproto.HoldCrewRecord
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
continue
}
if crewRecord.Member == did {
// Found crew membership
return true, nil
}
}
return false, nil
}
+134
View File
@@ -0,0 +1,134 @@
package hold
import (
"fmt"
"os"
"time"
"github.com/distribution/distribution/v3/configuration"
)
// Config represents the hold service configuration
type Config struct {
Version string `yaml:"version"`
Storage StorageConfig `yaml:"storage"`
Server ServerConfig `yaml:"server"`
Registration RegistrationConfig `yaml:"registration"`
}
// RegistrationConfig defines auto-registration settings
type RegistrationConfig struct {
// OwnerDID is the owner's ATProto DID (from env: HOLD_OWNER)
// If set, auto-registration is enabled
OwnerDID string `yaml:"owner_did"`
}
// StorageConfig wraps distribution's storage configuration
type StorageConfig struct {
configuration.Storage `yaml:",inline"`
}
// ServerConfig defines server settings
type ServerConfig struct {
// Addr is the address to listen on (e.g., ":8080")
Addr string `yaml:"addr"`
// PublicURL is the public URL of this hold service (e.g., "https://hold.example.com")
PublicURL string `yaml:"public_url"`
// Public controls whether this hold allows public blob reads without auth (from env: HOLD_PUBLIC)
Public bool `yaml:"public"`
// TestMode uses localhost for OAuth redirects while storing real URL in hold record (from env: TEST_MODE)
TestMode bool `yaml:"test_mode"`
// DisablePresignedURLs forces proxy mode even with S3 configured (for testing) (from env: DISABLE_PRESIGNED_URLS)
DisablePresignedURLs bool `yaml:"disable_presigned_urls"`
// ReadTimeout for HTTP requests
ReadTimeout time.Duration `yaml:"read_timeout"`
// WriteTimeout for HTTP requests
WriteTimeout time.Duration `yaml:"write_timeout"`
}
// LoadConfigFromEnv loads all configuration from environment variables
func LoadConfigFromEnv() (*Config, error) {
cfg := &Config{
Version: "0.1",
}
// Server configuration
cfg.Server.Addr = getEnvOrDefault("HOLD_SERVER_ADDR", ":8080")
cfg.Server.PublicURL = os.Getenv("HOLD_PUBLIC_URL")
if cfg.Server.PublicURL == "" {
return nil, fmt.Errorf("HOLD_PUBLIC_URL is required")
}
cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true"
cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
cfg.Server.DisablePresignedURLs = os.Getenv("DISABLE_PRESIGNED_URLS") == "true"
cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads
cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads
// Registration configuration (optional)
cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER")
// Storage configuration - build from env vars based on storage type
storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")
var err error
cfg.Storage, err = buildStorageConfig(storageType)
if err != nil {
return nil, fmt.Errorf("failed to build storage config: %w", err)
}
return cfg, nil
}
// buildStorageConfig creates storage configuration based on driver type
func buildStorageConfig(driver string) (StorageConfig, error) {
params := make(map[string]any)
switch driver {
case "s3":
// S3/Storj/Minio configuration from standard AWS env vars
accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
region := getEnvOrDefault("AWS_REGION", "us-east-1")
bucket := os.Getenv("S3_BUCKET")
endpoint := os.Getenv("S3_ENDPOINT") // For Storj/Minio
if bucket == "" {
return StorageConfig{}, fmt.Errorf("S3_BUCKET is required for S3 storage")
}
params["accesskey"] = accessKey
params["secretkey"] = secretKey
params["region"] = region
params["bucket"] = bucket
if endpoint != "" {
params["regionendpoint"] = endpoint
}
case "filesystem":
// Filesystem configuration
rootDir := getEnvOrDefault("STORAGE_ROOT_DIR", "/var/lib/atcr/hold")
params["rootdirectory"] = rootDir
default:
return StorageConfig{}, fmt.Errorf("unsupported storage driver: %s", driver)
}
// Build distribution Storage config
storageCfg := configuration.Storage{}
storageCfg[driver] = configuration.Parameters(params)
return StorageConfig{Storage: storageCfg}, nil
}
// getEnvOrDefault gets an environment variable or returns a default value
func getEnvOrDefault(key, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultValue
}
+587
View File
@@ -0,0 +1,587 @@
package hold
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
"atcr.io/pkg/atproto"
)
// 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",
})
}
// 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)
}
+381
View File
@@ -0,0 +1,381 @@
package hold
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
"github.com/google/uuid"
)
// MultipartMode indicates how multipart uploads are handled
type MultipartMode int
const (
// S3Native uses S3's native multipart API with presigned URLs
S3Native MultipartMode = iota
// Buffered buffers parts in memory and assembles them in the hold service
Buffered
)
// MultipartSession tracks an in-progress multipart upload
type MultipartSession struct {
UploadID string // Unique upload ID
Digest string // Target digest path
Mode MultipartMode // Upload mode (S3Native or Buffered)
S3UploadID string // S3 upload ID (for S3Native mode)
Parts map[int]*MultipartPart // Buffered parts (for Buffered mode)
CreatedAt time.Time // When upload started
LastActivity time.Time // Last part upload
mu sync.RWMutex // Protects Parts map
}
// MultipartPart represents a single part in a multipart upload
type MultipartPart struct {
PartNumber int // Part number (1-indexed)
Data []byte // Part data (for Buffered mode)
ETag string // ETag from S3 or computed hash
Size int64 // Part size in bytes
UploadedAt time.Time // When part was uploaded
}
// MultipartManager manages multipart upload sessions
type MultipartManager struct {
sessions map[string]*MultipartSession // uploadID -> session
mu sync.RWMutex // Protects sessions map
}
// NewMultipartManager creates a new multipart manager
func NewMultipartManager() *MultipartManager {
mgr := &MultipartManager{
sessions: make(map[string]*MultipartSession),
}
// Start cleanup goroutine for abandoned uploads
go mgr.cleanupLoop()
return mgr
}
// cleanupLoop periodically cleans up expired sessions
func (m *MultipartManager) cleanupLoop() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for range ticker.C {
m.cleanupExpiredSessions()
}
}
// cleanupExpiredSessions removes sessions inactive for >24 hours
func (m *MultipartManager) cleanupExpiredSessions() {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
for uploadID, session := range m.sessions {
if now.Sub(session.LastActivity) > 24*time.Hour {
log.Printf("Cleaning up expired multipart session: uploadID=%s, age=%v", uploadID, now.Sub(session.CreatedAt))
delete(m.sessions, uploadID)
}
}
}
// CreateSession creates a new multipart upload session
func (m *MultipartManager) CreateSession(digest string, mode MultipartMode, s3UploadID string) *MultipartSession {
uploadID := uuid.New().String()
session := &MultipartSession{
UploadID: uploadID,
Digest: digest,
Mode: mode,
S3UploadID: s3UploadID,
Parts: make(map[int]*MultipartPart),
CreatedAt: time.Now(),
LastActivity: time.Now(),
}
m.mu.Lock()
m.sessions[uploadID] = session
m.mu.Unlock()
log.Printf("Created multipart session: uploadID=%s, digest=%s, mode=%v", uploadID, digest, mode)
return session
}
// GetSession retrieves a multipart session by upload ID
func (m *MultipartManager) GetSession(uploadID string) (*MultipartSession, error) {
m.mu.RLock()
defer m.mu.RUnlock()
session, ok := m.sessions[uploadID]
if !ok {
return nil, fmt.Errorf("multipart session not found: %s", uploadID)
}
return session, nil
}
// DeleteSession removes a multipart session
func (m *MultipartManager) DeleteSession(uploadID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions, uploadID)
log.Printf("Deleted multipart session: uploadID=%s", uploadID)
}
// StorePart stores a part in the session (for Buffered mode)
func (s *MultipartSession) StorePart(partNumber int, data []byte) string {
s.mu.Lock()
defer s.mu.Unlock()
// Compute ETag as SHA256 hash of part data
hash := sha256.Sum256(data)
etag := hex.EncodeToString(hash[:])
part := &MultipartPart{
PartNumber: partNumber,
Data: data,
ETag: etag,
Size: int64(len(data)),
UploadedAt: time.Now(),
}
s.Parts[partNumber] = part
s.LastActivity = time.Now()
log.Printf("Stored part: uploadID=%s, part=%d, size=%d bytes, etag=%s", s.UploadID, partNumber, len(data), etag)
return etag
}
// RecordS3Part records a part uploaded to S3 (for S3Native mode)
func (s *MultipartSession) RecordS3Part(partNumber int, etag string, size int64) {
s.mu.Lock()
defer s.mu.Unlock()
part := &MultipartPart{
PartNumber: partNumber,
ETag: etag,
Size: size,
UploadedAt: time.Now(),
}
s.Parts[partNumber] = part
s.LastActivity = time.Now()
log.Printf("Recorded S3 part: uploadID=%s, part=%d, size=%d bytes, etag=%s", s.UploadID, partNumber, size, etag)
}
// AssembleBufferedParts assembles all buffered parts into a single blob
// Returns the complete data and total size
func (s *MultipartSession) AssembleBufferedParts() ([]byte, int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.Mode != Buffered {
return nil, 0, fmt.Errorf("session is not in buffered mode")
}
// Calculate total size
var totalSize int64
maxPart := 0
for partNum, part := range s.Parts {
totalSize += part.Size
if partNum > maxPart {
maxPart = partNum
}
}
// Check for missing parts
for i := 1; i <= maxPart; i++ {
if _, ok := s.Parts[i]; !ok {
return nil, 0, fmt.Errorf("missing part %d", i)
}
}
// Assemble parts in order
assembled := make([]byte, 0, totalSize)
for i := 1; i <= maxPart; i++ {
part := s.Parts[i]
assembled = append(assembled, part.Data...)
}
log.Printf("Assembled buffered parts: uploadID=%s, parts=%d, totalSize=%d bytes", s.UploadID, maxPart, totalSize)
return assembled, totalSize, nil
}
// GetCompletedParts returns the list of completed parts for S3 multipart completion
func (s *MultipartSession) GetCompletedParts() []CompletedPart {
s.mu.RLock()
defer s.mu.RUnlock()
parts := make([]CompletedPart, 0, len(s.Parts))
for _, part := range s.Parts {
parts = append(parts, CompletedPart{
PartNumber: part.PartNumber,
ETag: part.ETag,
})
}
return parts
}
// StartMultipartUploadWithManager initiates a multipart upload using the manager
// Returns uploadID and mode
func (s *HoldService) StartMultipartUploadWithManager(ctx context.Context, digest string, manager *MultipartManager) (string, MultipartMode, error) {
// Check if presigned URLs are disabled for testing
if s.config.Server.DisablePresignedURLs {
log.Printf("Presigned URLs disabled (DISABLE_PRESIGNED_URLS=true), using buffered mode")
session := manager.CreateSession(digest, Buffered, "")
log.Printf("Started buffered multipart: uploadID=%s", session.UploadID)
return session.UploadID, Buffered, nil
}
// Try S3 native multipart first
if s.s3Client != nil {
s3UploadID, err := s.startMultipartUpload(ctx, digest)
if err == nil {
// S3 native multipart succeeded
session := manager.CreateSession(digest, S3Native, s3UploadID)
log.Printf("Started S3 native multipart: uploadID=%s, s3UploadID=%s", session.UploadID, s3UploadID)
return session.UploadID, S3Native, nil
}
log.Printf("S3 native multipart failed, falling back to buffered mode: %v", err)
}
// Fallback to buffered mode
session := manager.CreateSession(digest, Buffered, "")
log.Printf("Started buffered multipart: uploadID=%s", session.UploadID)
return session.UploadID, Buffered, nil
}
// GetPartUploadURL generates a URL for uploading a part
// For S3Native: returns presigned URL
// For Buffered: returns proxy endpoint
func (s *HoldService) GetPartUploadURL(ctx context.Context, session *MultipartSession, partNumber int, did string) (string, error) {
if session.Mode == S3Native {
// Generate S3 presigned URL for this part
url, err := s.getPartPresignedURL(ctx, session.Digest, session.S3UploadID, partNumber)
if err != nil {
return "", fmt.Errorf("failed to generate S3 part URL: %w", err)
}
return url, nil
}
// Buffered mode: return proxy endpoint
url := fmt.Sprintf("%s/multipart-parts/%s/%d?did=%s",
s.config.Server.PublicURL, session.UploadID, partNumber, did)
return url, nil
}
// CompleteMultipartUploadWithManager completes a multipart upload
func (s *HoldService) CompleteMultipartUploadWithManager(ctx context.Context, session *MultipartSession, manager *MultipartManager) error {
defer manager.DeleteSession(session.UploadID)
if session.Mode == S3Native {
// Complete S3 multipart upload
parts := session.GetCompletedParts()
if err := s.completeMultipartUpload(ctx, session.Digest, session.S3UploadID, parts); err != nil {
return fmt.Errorf("failed to complete S3 multipart: %w", err)
}
log.Printf("Completed S3 native multipart: uploadID=%s, parts=%d", session.UploadID, len(parts))
return nil
}
// Buffered mode: assemble parts and write via driver
data, size, err := session.AssembleBufferedParts()
if err != nil {
return fmt.Errorf("failed to assemble parts: %w", err)
}
// Write assembled blob to storage
path := blobPath(session.Digest)
writer, err := s.driver.Writer(ctx, path, false)
if err != nil {
return fmt.Errorf("failed to create writer: %w", err)
}
written, err := writer.Write(data)
if err != nil {
writer.Cancel(ctx)
return fmt.Errorf("failed to write blob: %w", err)
}
if err := writer.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit blob: %w", err)
}
log.Printf("Completed buffered multipart: uploadID=%s, size=%d bytes, written=%d", session.UploadID, size, written)
return nil
}
// AbortMultipartUploadWithManager aborts a multipart upload
func (s *HoldService) AbortMultipartUploadWithManager(ctx context.Context, session *MultipartSession, manager *MultipartManager) error {
defer manager.DeleteSession(session.UploadID)
if session.Mode == S3Native {
// Abort S3 multipart upload
if err := s.abortMultipartUpload(ctx, session.Digest, session.S3UploadID); err != nil {
return fmt.Errorf("failed to abort S3 multipart: %w", err)
}
log.Printf("Aborted S3 native multipart: uploadID=%s", session.UploadID)
return nil
}
// Buffered mode: just delete the session (parts are in memory)
log.Printf("Aborted buffered multipart: uploadID=%s", session.UploadID)
return nil
}
// HandleMultipartPartUpload handles uploading a part in buffered mode
// This is a new endpoint: PUT /multipart-parts/{uploadID}/{partNumber}
func (s *HoldService) HandleMultipartPartUpload(w http.ResponseWriter, r *http.Request, uploadID string, partNumber int, did string, manager *MultipartManager) {
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Get session
session, err := manager.GetSession(uploadID)
if err != nil {
http.Error(w, fmt.Sprintf("session not found: %v", err), http.StatusNotFound)
return
}
// Verify authorization
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
}
// Verify session is in buffered mode
if session.Mode != Buffered {
http.Error(w, "session is not in buffered mode", http.StatusBadRequest)
return
}
// Read part data
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to read part data: %v", err), http.StatusInternalServerError)
return
}
// Store part and get ETag
etag := session.StorePart(partNumber, data)
// Return ETag in response
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusOK)
}
+267
View File
@@ -0,0 +1,267 @@
package hold
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// 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"}`))
}
// isHoldRegistered checks if a hold with the given public URL is already registered in the PDS
func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) {
// We need to query the PDS without authentication to check public records
// ATProto records are publicly readable, so we can use an unauthenticated client
client := atproto.NewClient(pdsEndpoint, did, "")
// List all hold records for this DID
records, err := client.ListRecords(ctx, atproto.HoldCollection, 100)
if err != nil {
return false, fmt.Errorf("failed to list hold records: %w", err)
}
// Check if any hold record matches our public URL
for _, record := range records {
var holdRecord atproto.HoldRecord
if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
continue
}
if holdRecord.Endpoint == publicURL {
return true, nil
}
}
return false, nil
}
// AutoRegister registers this hold service in the owner's PDS
// Checks if already registered first, then does OAuth if needed
func (s *HoldService) AutoRegister(callbackHandler *http.HandlerFunc) error {
reg := &s.config.Registration
publicURL := s.config.Server.PublicURL
if publicURL == "" {
return fmt.Errorf("HOLD_PUBLIC_URL not set")
}
if reg.OwnerDID == "" {
return fmt.Errorf("HOLD_OWNER not set - required for registration")
}
ctx := context.Background()
log.Printf("Checking registration status for DID: %s", reg.OwnerDID)
// Resolve DID to PDS endpoint using indigo
directory := identity.DefaultDirectory()
didParsed, err := syntax.ParseDID(reg.OwnerDID)
if err != nil {
return fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, didParsed)
if err != nil {
return fmt.Errorf("failed to resolve PDS for DID: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return fmt.Errorf("no PDS endpoint found for DID")
}
log.Printf("PDS endpoint: %s", pdsEndpoint)
// Check if hold is already registered
isRegistered, err := s.isHoldRegistered(ctx, reg.OwnerDID, pdsEndpoint, publicURL)
if err != nil {
log.Printf("Warning: failed to check registration status: %v", err)
log.Printf("Proceeding with OAuth registration...")
} else if isRegistered {
log.Printf("✓ Hold service already registered in PDS")
log.Printf("Public URL: %s", publicURL)
return nil
}
// Not registered, need to do OAuth
log.Printf("Hold not registered, starting OAuth flow...")
// Get handle from DID document (already resolved above)
handle := ident.Handle.String()
if handle == "" || handle == "handle.invalid" {
return fmt.Errorf("no valid handle found for DID")
}
log.Printf("Resolved handle: %s", handle)
log.Printf("Starting OAuth registration for hold service")
log.Printf("Public URL: %s", publicURL)
return s.registerWithOAuth(publicURL, handle, reg.OwnerDID, pdsEndpoint, callbackHandler)
}
// registerWithOAuth performs OAuth flow and registers the hold
func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string, callbackHandler *http.HandlerFunc) error {
// Define the scopes we need for hold registration
holdScopes := []string{
"atproto",
fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection),
fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection),
fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s?action=create", atproto.SailorProfileCollection),
fmt.Sprintf("repo:%s?action=update", atproto.SailorProfileCollection),
}
// Determine base URL based on mode
// Callback path standardized to /auth/oauth/callback across ATCR
var baseURL string
if s.config.Server.TestMode {
// Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record
// Extract port from publicURL (e.g., "http://172.28.0.3:8080" -> ":8080")
parsedURL, err := url.Parse(publicURL)
if err != nil {
return fmt.Errorf("failed to parse public URL: %w", err)
}
port := parsedURL.Port()
if port == "" {
port = "8080" // default
}
baseURL = fmt.Sprintf("http://127.0.0.1:%s", port)
} else {
baseURL = publicURL
}
// Run interactive OAuth flow with persistent server
ctx := context.Background()
result, err := oauth.InteractiveFlowWithCallback(
ctx,
baseURL,
handle,
holdScopes, // Pass hold-specific scopes
func(handler http.HandlerFunc) error {
// Populate the pre-registered callback handler
*callbackHandler = handler
return nil
},
func(authURL string) error {
// Display OAuth URL for user to visit
log.Print("\n" + strings.Repeat("=", 80))
log.Printf("OAUTH AUTHORIZATION REQUIRED")
log.Print(strings.Repeat("=", 80))
log.Printf("\nPlease visit this URL to authorize the hold service:\n")
log.Printf(" %s\n", authURL)
log.Printf("Waiting for authorization...")
log.Print(strings.Repeat("=", 80) + "\n")
return nil
},
)
if err != nil {
return err
}
log.Printf("Authorization received!")
log.Printf("OAuth session obtained successfully")
log.Printf("DID: %s", did)
log.Printf("PDS: %s", pdsEndpoint)
// Create ATProto client with indigo's API client (handles DPoP automatically)
apiClient := result.Session.APIClient()
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
return s.registerWithClient(publicURL, did, client)
}
// registerWithClient registers the hold using an authenticated ATProto client
func (s *HoldService) registerWithClient(publicURL, did string, client *atproto.Client) error {
// Derive hold name from URL (hostname)
holdName, err := extractHostname(publicURL)
if err != nil {
return fmt.Errorf("failed to extract hostname from URL: %w", err)
}
log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did)
ctx := context.Background()
// Create HoldRecord
holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public)
// Use hostname as record key
holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord)
if err != nil {
return fmt.Errorf("failed to create hold record: %w", err)
}
log.Printf("✓ Created hold record: %s", holdResult.URI)
// Create HoldCrewRecord for the owner
crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, did, "owner")
crewRKey := fmt.Sprintf("%s-%s", holdName, did)
crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
if err != nil {
return fmt.Errorf("failed to create crew record: %w", err)
}
log.Printf("✓ Created crew record: %s", crewResult.URI)
// Update sailor profile to set this as the default hold
profile, err := atproto.GetProfile(ctx, client)
if err != nil {
log.Printf("Warning: failed to get sailor profile: %v", err)
} else {
if profile == nil {
// Create new profile with this hold as default
profile = atproto.NewSailorProfileRecord(publicURL)
} else {
// Update existing profile with new defaultHold
profile.DefaultHold = publicURL
profile.UpdatedAt = time.Now()
}
err = atproto.UpdateProfile(ctx, client, profile)
if err != nil {
log.Printf("Warning: failed to update sailor profile: %v", err)
} else {
log.Printf("✓ Updated sailor profile defaultHold: %s", publicURL)
}
}
log.Print("\n" + strings.Repeat("=", 80))
log.Printf("REGISTRATION COMPLETE")
log.Print(strings.Repeat("=", 80))
log.Printf("Hold service is now registered and ready to use!")
log.Print(strings.Repeat("=", 80) + "\n")
return nil
}
// extractHostname extracts the hostname from a URL to use as the hold name
func extractHostname(urlStr string) (string, error) {
u, err := url.Parse(urlStr)
if err != nil {
return "", err
}
// Remove port if present
hostname := u.Hostname()
if hostname == "" {
return "", fmt.Errorf("no hostname in URL")
}
return hostname, nil
}
+221
View File
@@ -0,0 +1,221 @@
package hold
import (
"context"
"fmt"
"log"
"sort"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
)
// initS3Client initializes the S3 client for presigned URL generation
// Returns nil error if S3 client is successfully initialized
// Returns error if storage is not S3 or if initialization fails (service will fall back to proxy mode)
func (s *HoldService) initS3Client() error {
// Check if presigned URLs are explicitly disabled
if s.config.Server.DisablePresignedURLs {
log.Printf("⚠️ S3 presigned URLs DISABLED by config (DISABLE_PRESIGNED_URLS=true)")
log.Printf(" All uploads will use buffered mode (parts buffered in hold service)")
return nil // Not an error - just using buffered mode
}
// Check if storage driver is S3
if s.config.Storage.Type() != "s3" {
log.Printf("Storage driver is %s (not S3), presigned URLs disabled", s.config.Storage.Type())
return nil // Not an error - just using different driver
}
// Extract S3 configuration from storage parameters
params := s.config.Storage.Parameters()
// Extract required S3 configuration
region, _ := params["region"].(string)
if region == "" {
region = "us-east-1" // Default region
}
accessKey, _ := params["accesskey"].(string)
secretKey, _ := params["secretkey"].(string)
bucket, _ := params["bucket"].(string)
if bucket == "" {
return fmt.Errorf("S3 bucket not configured")
}
// Build AWS config
awsConfig := &aws.Config{
Region: aws.String(region),
}
// Add credentials if provided (allow IAM role auth if not provided)
if accessKey != "" && secretKey != "" {
awsConfig.Credentials = credentials.NewStaticCredentials(accessKey, secretKey, "")
}
// Add custom endpoint for S3-compatible services (Storj, MinIO, R2, etc.)
if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" {
awsConfig.Endpoint = aws.String(endpoint)
awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj
}
// Create AWS session
sess, err := session.NewSession(awsConfig)
if err != nil {
return fmt.Errorf("failed to create AWS session: %w", err)
}
// Create S3 client
s.s3Client = s3.New(sess)
s.bucket = bucket
// Extract path prefix if configured (rootdirectory in S3 params)
if rootDir, ok := params["rootdirectory"].(string); ok && rootDir != "" {
s.s3PathPrefix = strings.TrimPrefix(rootDir, "/")
}
log.Printf("✅ S3 presigned URLs enabled")
return nil
}
// startMultipartUpload initiates a multipart upload and returns upload ID
func (s *HoldService) startMultipartUpload(ctx context.Context, digest string) (string, error) {
if s.s3Client == nil {
return "", fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
result, err := s.s3Client.CreateMultipartUploadWithContext(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
if err != nil {
return "", err
}
log.Printf("Started multipart upload: digest=%s, uploadID=%s", digest, *result.UploadId)
return *result.UploadId, nil
}
// getPartPresignedURL generates presigned URL for a specific part
func (s *HoldService) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) {
if s.s3Client == nil {
return "", fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
req, _ := s.s3Client.UploadPartRequest(&s3.UploadPartInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
UploadId: aws.String(uploadID),
PartNumber: aws.Int64(int64(partNumber)),
})
url, err := req.Presign(15 * time.Minute)
if err != nil {
return "", err
}
log.Printf("Generated part presigned URL: digest=%s, uploadID=%s, part=%d", digest, uploadID, partNumber)
return url, nil
}
// normalizeETag ensures an ETag has quotes (required by S3 CompleteMultipartUpload)
// S3 returns ETags with quotes, but HTTP clients may strip them
func normalizeETag(etag string) string {
// Already has quotes
if strings.HasPrefix(etag, "\"") && strings.HasSuffix(etag, "\"") {
return etag
}
// Add quotes
return fmt.Sprintf("\"%s\"", etag)
}
// completeMultipartUpload finalizes the multipart upload
func (s *HoldService) completeMultipartUpload(ctx context.Context, digest, uploadID string, parts []CompletedPart) error {
if s.s3Client == nil {
return fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
// Sort parts by part number (S3 requires ascending order)
sort.Slice(parts, func(i, j int) bool {
return parts[i].PartNumber < parts[j].PartNumber
})
// Convert to S3 CompletedPart format
// IMPORTANT: S3 requires ETags to be quoted in the CompleteMultipartUpload XML
s3Parts := make([]*s3.CompletedPart, len(parts))
for i, p := range parts {
etag := normalizeETag(p.ETag)
s3Parts[i] = &s3.CompletedPart{
PartNumber: aws.Int64(int64(p.PartNumber)),
ETag: aws.String(etag),
}
}
_, err := s.s3Client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
UploadId: aws.String(uploadID),
MultipartUpload: &s3.CompletedMultipartUpload{
Parts: s3Parts,
},
})
if err != nil {
log.Printf("Failed to complete multipart upload: digest=%s, uploadID=%s, err=%v", digest, uploadID, err)
return err
}
log.Printf("Completed multipart upload: digest=%s, uploadID=%s, parts=%d", digest, uploadID, len(parts))
return nil
}
// abortMultipartUpload aborts an in-progress multipart upload
func (s *HoldService) abortMultipartUpload(ctx context.Context, digest, uploadID string) error {
if s.s3Client == nil {
return fmt.Errorf("S3 not configured")
}
path := blobPath(digest)
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
_, err := s.s3Client.AbortMultipartUploadWithContext(ctx, &s3.AbortMultipartUploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
UploadId: aws.String(uploadID),
})
if err != nil {
log.Printf("Failed to abort multipart upload: digest=%s, uploadID=%s, err=%v", digest, uploadID, err)
return err
}
log.Printf("Aborted multipart upload: digest=%s, uploadID=%s", digest, uploadID)
return nil
}
+44
View File
@@ -0,0 +1,44 @@
package hold
import (
"context"
"fmt"
"log"
"github.com/aws/aws-sdk-go/service/s3"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
)
// HoldService provides presigned URLs for blob storage in a hold
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
}
// NewHoldService creates a new hold service
func NewHoldService(cfg *Config) (*HoldService, error) {
// Create storage driver from config
ctx := context.Background()
driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters())
if err != nil {
return nil, fmt.Errorf("failed to create storage driver: %w", err)
}
service := &HoldService{
driver: driver,
config: cfg,
MultipartMgr: NewMultipartManager(),
}
// Initialize S3 client for presigned URLs (if using S3 storage)
if err := service.initS3Client(); err != nil {
log.Printf("WARNING: S3 presigned URLs disabled: %v", err)
}
return service, nil
}
+115
View File
@@ -0,0 +1,115 @@
package hold
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
)
// blobPath converts a digest (e.g., "sha256:abc123...") or temp path to a storage path
// Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data
// where xx is the first 2 characters of the hash for directory sharding
// NOTE: Path must start with / for filesystem driver
func blobPath(digest string) string {
// Handle temp paths (start with uploads/temp-)
if strings.HasPrefix(digest, "uploads/temp-") {
return fmt.Sprintf("/docker/registry/v2/%s/data", digest)
}
// Split digest into algorithm and hash
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 {
// Fallback for malformed digest
return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
}
algorithm := parts[0]
hash := parts[1]
// Use first 2 characters for sharding
if len(hash) < 2 {
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash)
}
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash)
}
// getPresignedURL generates a presigned URL for GET, HEAD, or PUT operations
func (s *HoldService) getPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) {
path := blobPath(digest)
// Check blob exists for GET/HEAD operations (not for PUT since blob doesn't exist yet)
if operation == OperationGet || operation == OperationHead {
if _, err := s.driver.Stat(ctx, path); err != nil {
return "", fmt.Errorf("blob not found: %w", err)
}
}
// 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
}
// Generate presigned URL if S3 client is available
if s.s3Client != nil {
// Build S3 key from blob path
s3Key := strings.TrimPrefix(path, "/")
if s.s3PathPrefix != "" {
s3Key = s.s3PathPrefix + "/" + s3Key
}
// Create appropriate S3 request based on operation
var req interface {
Presign(time.Duration) (string, error)
}
switch operation {
case OperationGet:
// Note: Don't use ResponseContentType - not supported by all S3-compatible services
req, _ = s.s3Client.GetObjectRequest(&s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
case OperationHead:
req, _ = s.s3Client.HeadObjectRequest(&s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
case OperationPut:
req, _ = s.s3Client.PutObjectRequest(&s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
ContentType: aws.String("application/octet-stream"),
})
default:
return "", fmt.Errorf("unsupported operation: %s", operation)
}
// Generate presigned URL with 15 minute expiry
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
}
return url, nil
}
// Fallback: return proxy URL through this service
return s.getProxyURL(digest, did), 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)
}
-54
View File
@@ -1,54 +0,0 @@
package storage
import (
"context"
"github.com/distribution/distribution/v3"
"github.com/distribution/distribution/v3/registry/storage"
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/distribution/reference"
)
// S3BlobStore wraps distribution's blob store with S3 backend
type S3BlobStore struct {
distribution.BlobStore
}
// NewS3BlobStore creates a new S3-backed blob store
func NewS3BlobStore(ctx context.Context, storageDriver driver.StorageDriver, repoName string) (*S3BlobStore, error) {
// Create a registry instance with the S3 driver
reg, err := storage.NewRegistry(ctx, storageDriver)
if err != nil {
return nil, err
}
// Parse the repository name into a Named reference
named, err := reference.ParseNamed(repoName)
if err != nil {
return nil, err
}
// Get the repository
repo, err := reg.Repository(ctx, named)
if err != nil {
return nil, err
}
// Get the blob store
blobStore := repo.Blobs(ctx)
return &S3BlobStore{
BlobStore: blobStore,
}, nil
}
// Note: S3BlobStore inherits all methods from distribution.BlobStore
// including:
// - Stat(ctx, dgst) - Check if blob exists
// - Get(ctx, dgst) - Retrieve blob
// - Open(ctx, dgst) - Open blob for reading
// - Put(ctx, mediaType, payload) - Store blob
// - Create(ctx, options...) - Create blob writer
// - Resume(ctx, id) - Resume blob upload
// - ServeBlob(ctx, w, r, dgst) - Serve blob over HTTP
// - Delete(ctx, dgst) - Delete blob