mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-27 03:35:10 +00:00
237 lines
8.2 KiB
Go
237 lines
8.2 KiB
Go
// Package hold implements the ATCR hold service, which provides BYOS
|
|
// (Bring Your Own Storage) functionality. It includes an embedded PDS for
|
|
// storing captain and crew records, generates presigned URLs for blob storage,
|
|
// and handles authorization based on crew membership. Configuration is loaded
|
|
// entirely from environment variables.
|
|
package hold
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/distribution/distribution/v3/configuration"
|
|
)
|
|
|
|
// Config represents the hold service configuration
|
|
type Config struct {
|
|
Version string `yaml:"version"`
|
|
LogLevel string `yaml:"log_level"`
|
|
Storage StorageConfig `yaml:"storage"`
|
|
Server ServerConfig `yaml:"server"`
|
|
Registration RegistrationConfig `yaml:"registration"`
|
|
Database DatabaseConfig `yaml:"database"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// AllowAllCrew controls whether to create a wildcard crew record (from env: HOLD_ALLOW_ALL_CREW)
|
|
// If true, creates/maintains a crew record with memberPattern: "*" (allows all authenticated users)
|
|
// If false, deletes the wildcard crew record if it exists
|
|
AllowAllCrew bool `yaml:"allow_all_crew"`
|
|
|
|
// ProfileAvatarURL is the URL to download the avatar image from (from env: HOLD_PROFILE_AVATAR)
|
|
// If set, the avatar will be downloaded and uploaded as a blob during bootstrap
|
|
ProfileAvatarURL string `yaml:"profile_avatar_url"`
|
|
|
|
// EnableBlueskyPosts controls whether to create Bluesky posts for manifest uploads (from env: HOLD_BLUESKY_POSTS_ENABLED)
|
|
// If true, creates posts when users push images
|
|
// Synced to captain record's enableBlueskyPosts field on startup
|
|
EnableBlueskyPosts bool `yaml:"enable_bluesky_posts"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// RelayEndpoint is the ATProto relay URL to request crawl from on startup (from env: HOLD_RELAY_ENDPOINT)
|
|
// If empty, no crawl request is made. Default: https://bsky.network
|
|
RelayEndpoint string `yaml:"relay_endpoint"`
|
|
|
|
// ReadTimeout for HTTP requests
|
|
ReadTimeout time.Duration `yaml:"read_timeout"`
|
|
|
|
// WriteTimeout for HTTP requests
|
|
WriteTimeout time.Duration `yaml:"write_timeout"`
|
|
}
|
|
|
|
// DatabaseConfig defines embedded PDS database settings
|
|
type DatabaseConfig struct {
|
|
// Path is the directory path for carstore (from env: HOLD_DATABASE_DIR)
|
|
// If empty, embedded PDS is disabled
|
|
Path string `yaml:"path"`
|
|
|
|
// KeyPath is the path to the signing key (from env: HOLD_KEY_PATH)
|
|
// Defaults to {Path}/signing.key
|
|
KeyPath string `yaml:"key_path"`
|
|
}
|
|
|
|
// LoadConfigFromEnv loads all configuration from environment variables
|
|
func LoadConfigFromEnv() (*Config, error) {
|
|
cfg := &Config{
|
|
Version: "0.1",
|
|
}
|
|
|
|
// Logging configuration
|
|
cfg.LogLevel = getEnvOrDefault("ATCR_LOG_LEVEL", "info")
|
|
|
|
// 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.RelayEndpoint = os.Getenv("HOLD_RELAY_ENDPOINT")
|
|
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")
|
|
cfg.Registration.AllowAllCrew = os.Getenv("HOLD_ALLOW_ALL_CREW") == "true"
|
|
cfg.Registration.ProfileAvatarURL = getEnvOrDefault("HOLD_PROFILE_AVATAR", "https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE")
|
|
cfg.Registration.EnableBlueskyPosts = os.Getenv("HOLD_BLUESKY_POSTS_ENABLED") == "true"
|
|
|
|
// Database configuration (optional - enables embedded PDS)
|
|
// Note: HOLD_DATABASE_DIR is a directory path, carstore creates db.sqlite3 inside it
|
|
cfg.Database.Path = getEnvOrDefault("HOLD_DATABASE_DIR", "/var/lib/atcr-hold")
|
|
cfg.Database.KeyPath = os.Getenv("HOLD_KEY_PATH")
|
|
if cfg.Database.KeyPath == "" && cfg.Database.Path != "" {
|
|
// Default: signing key in same directory as carstore
|
|
cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// RequestCrawl sends a crawl request to the ATProto relay for the given hostname.
|
|
// This makes the hold's PDS discoverable by the relay network.
|
|
func RequestCrawl(relayEndpoint, publicURL string) error {
|
|
if relayEndpoint == "" {
|
|
return nil // No relay configured, skip
|
|
}
|
|
|
|
// Extract hostname from public URL
|
|
parsed, err := url.Parse(publicURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse public URL: %w", err)
|
|
}
|
|
hostname := parsed.Host
|
|
|
|
// Build the request URL
|
|
requestURL := relayEndpoint + "/xrpc/com.atproto.sync.requestCrawl"
|
|
|
|
// Create request body
|
|
body := map[string]string{"hostname": hostname}
|
|
bodyJSON, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal request body: %w", err)
|
|
}
|
|
|
|
// Make the request
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
req, err := http.NewRequest("POST", requestURL, bytes.NewReader(bodyJSON))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("relay returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|