mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-30 12:46:57 +00:00
1042 lines
31 KiB
Go
1042 lines
31 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/distribution/distribution/v3/configuration"
|
|
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
|
|
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth/oauth"
|
|
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
|
"github.com/bluesky-social/indigo/atproto/identity"
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
|
|
// Import storage drivers
|
|
_ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
|
|
_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"
|
|
)
|
|
|
|
// 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"`
|
|
|
|
// ReadTimeout for HTTP requests
|
|
ReadTimeout time.Duration `yaml:"read_timeout"`
|
|
|
|
// WriteTimeout for HTTP requests
|
|
WriteTimeout time.Duration `yaml:"write_timeout"`
|
|
}
|
|
|
|
// HoldService provides presigned URLs for blob storage in a hold
|
|
type HoldService struct {
|
|
driver storagedriver.StorageDriver
|
|
config *Config
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
return &HoldService{
|
|
driver: driver,
|
|
config: cfg,
|
|
}, nil
|
|
}
|
|
|
|
// GetPresignedURLRequest represents a request for a presigned download URL
|
|
type GetPresignedURLRequest struct {
|
|
DID string `json:"did"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
|
|
// GetPresignedURLResponse contains the presigned URL
|
|
type GetPresignedURLResponse struct {
|
|
URL string `json:"url"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
// PutPresignedURLRequest represents a request for a presigned upload URL
|
|
type PutPresignedURLRequest struct {
|
|
DID string `json:"did"`
|
|
Digest string `json:"digest"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// PutPresignedURLResponse contains the presigned upload URL
|
|
type PutPresignedURLResponse struct {
|
|
URL string `json:"url"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
// HandleGetPresignedURL handles requests for download URLs
|
|
func (s *HoldService) HandleGetPresignedURL(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req GetPresignedURLRequest
|
|
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 READ
|
|
if !s.isAuthorizedRead(req.DID) {
|
|
if req.DID == "" {
|
|
// Anonymous request to private hold
|
|
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
|
|
} else {
|
|
// Authenticated but not authorized
|
|
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)
|
|
|
|
// For now, construct direct URL to blob
|
|
// In production, this would use driver-specific presigned URLs
|
|
url, err := s.getDownloadURL(ctx, req.Digest, req.DID)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := GetPresignedURLResponse{
|
|
URL: url,
|
|
ExpiresAt: expiry,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// HandlePutPresignedURL handles requests for upload URLs
|
|
func (s *HoldService) HandlePutPresignedURL(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req PutPresignedURLRequest
|
|
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 == "" {
|
|
// Anonymous write attempt
|
|
http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized)
|
|
} else {
|
|
// Authenticated but not crew/owner
|
|
http.Error(w, "forbidden: write access denied", http.StatusForbidden)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Generate presigned upload URL (15 minute expiry)
|
|
ctx := context.Background()
|
|
expiry := time.Now().Add(15 * time.Minute)
|
|
|
|
url, err := s.getUploadURL(ctx, req.Digest, req.Size, req.DID)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := PutPresignedURLResponse{
|
|
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")
|
|
}
|
|
|
|
// Authorize READ access
|
|
if !s.isAuthorizedRead(did) {
|
|
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) {
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// getDownloadURL generates a download URL for a blob
|
|
func (s *HoldService) getDownloadURL(ctx context.Context, digest string, did string) (string, error) {
|
|
// Check if blob exists
|
|
path := blobPath(digest)
|
|
_, err := s.driver.Stat(ctx, path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("blob not found: %w", err)
|
|
}
|
|
|
|
// For drivers that support presigned URLs (S3), use those
|
|
// For now, return a proxy URL through this service with DID for authorization
|
|
return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did), nil
|
|
}
|
|
|
|
// getUploadURL generates an upload URL for a blob
|
|
// Note: This is called from HandlePutPresignedURL which has the DID in the request
|
|
func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64, did string) (string, error) {
|
|
// For drivers that support presigned URLs (S3), use those
|
|
// For now, return a proxy URL through this service with DID for authorization
|
|
return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did), nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// HealthHandler handles health check requests
|
|
func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"status": "ok",
|
|
})
|
|
}
|
|
|
|
func main() {
|
|
// Load configuration from environment variables
|
|
cfg, err := loadConfigFromEnv()
|
|
if err != nil {
|
|
log.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
// Create hold service
|
|
service, err := NewHoldService(cfg)
|
|
if err != nil {
|
|
log.Fatalf("Failed to create hold service: %v", err)
|
|
}
|
|
|
|
// Setup HTTP routes
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/health", service.HealthHandler)
|
|
mux.HandleFunc("/register", service.HandleRegister)
|
|
mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL)
|
|
mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL)
|
|
mux.HandleFunc("/move", service.HandleMove)
|
|
|
|
// OAuth client metadata endpoint for ATProto OAuth
|
|
// The hold service serves its metadata at /client-metadata.json
|
|
// This is referenced by its client ID URL
|
|
mux.HandleFunc("/client-metadata.json", func(w http.ResponseWriter, r *http.Request) {
|
|
// Create a temporary config to generate metadata (indigo provides this)
|
|
redirectURI := cfg.Server.PublicURL + "/oauth/callback"
|
|
clientID := cfg.Server.PublicURL + "/client-metadata.json"
|
|
scopes := []string{"atproto"} // Hold service uses default scopes
|
|
|
|
config := indigooauth.NewPublicConfig(clientID, redirectURI, scopes)
|
|
metadata := config.ClientMetadata()
|
|
|
|
// Serve as JSON
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
json.NewEncoder(w).Encode(metadata)
|
|
})
|
|
mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead:
|
|
service.HandleProxyGet(w, r)
|
|
case http.MethodPut:
|
|
service.HandleProxyPut(w, r)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// Create server
|
|
server := &http.Server{
|
|
Addr: cfg.Server.Addr,
|
|
Handler: mux,
|
|
ReadTimeout: cfg.Server.ReadTimeout,
|
|
WriteTimeout: cfg.Server.WriteTimeout,
|
|
}
|
|
|
|
// Start server in goroutine so we can do auto-registration after it's running
|
|
serverErr := make(chan error, 1)
|
|
go func() {
|
|
log.Printf("Starting hold service on %s", cfg.Server.Addr)
|
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
serverErr <- err
|
|
}
|
|
}()
|
|
|
|
// Give server a moment to start
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Auto-register if owner DID is set (now that server is running)
|
|
if cfg.Registration.OwnerDID != "" {
|
|
if err := service.AutoRegister(); err != nil {
|
|
log.Printf("WARNING: Auto-registration failed: %v", err)
|
|
log.Printf("You can register manually later using the /register endpoint")
|
|
} else {
|
|
log.Printf("Successfully registered hold service in PDS")
|
|
}
|
|
}
|
|
|
|
// Wait for server error or shutdown
|
|
if err := <-serverErr; err != nil {
|
|
log.Fatalf("Server failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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.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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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() 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)
|
|
}
|
|
|
|
// registerWithOAuth performs OAuth flow and registers the hold
|
|
func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string) 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),
|
|
}
|
|
|
|
// 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()
|
|
|
|
// Note: holdScopes are ignored for now as indigo uses default scopes
|
|
// TODO: Enhance indigo App to support custom scopes if needed
|
|
_ = holdScopes
|
|
|
|
result, err := oauth.InteractiveFlowWithCallback(
|
|
ctx,
|
|
baseURL,
|
|
handle,
|
|
nil, // scopes (not used - indigo uses defaults)
|
|
func(handler http.HandlerFunc) error {
|
|
// Register callback on existing server (persistent server pattern)
|
|
http.HandleFunc("/auth/oauth/callback", 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
|
|
}
|