A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

deployment fixes. add open graph

evan.jarrett.net 044d408c 4063544c

verified
+141 -21
+7 -2
.env.hold.example
··· 29 29 AWS_SECRET_ACCESS_KEY=your_secret_key 30 30 31 31 # S3 Region 32 - # Examples: us-east-1, us-west-2, eu-west-1 33 - # For UpCloud: us-chi1, us-nyc1, de-fra1, uk-lon1, sg-sin1 32 + # For third-party S3 providers, this is ignored when S3_ENDPOINT is set, 33 + # but must be a valid AWS region (e.g., us-east-1) to pass validation. 34 34 # Default: us-east-1 35 35 AWS_REGION=us-east-1 36 36 ··· 60 60 # Writes (pushes) always require crew membership via PDS 61 61 # Default: false 62 62 HOLD_PUBLIC=false 63 + 64 + # ATProto relay endpoint for requesting crawl on startup 65 + # This makes the hold's embedded PDS discoverable by the relay network 66 + # Default: https://bsky.network (set to empty string to disable) 67 + # HOLD_RELAY_ENDPOINT=https://bsky.network 63 68 64 69 # ============================================================================== 65 70 # Embedded PDS Configuration
+1 -1
.tangled/workflows/tests.yml
··· 6 6 7 7 engine: kubernetes 8 8 image: golang:1.25-trixie 9 - architecture: arm64 9 + architecture: amd64 10 10 11 11 steps: 12 12 - name: Download and Generate
+23 -1
Makefile
··· 2 2 # Build targets for the ATProto Container Registry 3 3 4 4 .PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \ 5 - generate test test-race test-verbose lint clean help install-credential-helper 5 + generate test test-race test-verbose lint clean help install-credential-helper \ 6 + develop develop-detached develop-down 6 7 7 8 .DEFAULT_GOAL := help 8 9 ··· 79 80 @echo "→ Installing credential helper to /usr/local/sbin..." 80 81 install -m 755 bin/docker-credential-atcr /usr/local/sbin/docker-credential-atcr 81 82 @echo "✓ Installed docker-credential-atcr to /usr/local/sbin/" 83 + 84 + ##@ Docker Targets 85 + 86 + develop: ## Build Docker images and start docker-compose for development 87 + @echo "→ Building Docker images..." 88 + docker-compose build 89 + @echo "→ Starting docker-compose..." 90 + docker-compose up 91 + 92 + develop-detached: ## Build and start docker-compose in detached mode 93 + @echo "→ Building Docker images..." 94 + docker-compose build 95 + @echo "→ Starting docker-compose (detached)..." 96 + docker-compose up -d 97 + @echo "✓ Services started in background" 98 + @echo " AppView: http://localhost:5000" 99 + @echo " Hold: http://localhost:8080" 100 + 101 + develop-down: ## Stop docker-compose services 102 + @echo "→ Stopping docker-compose..." 103 + docker-compose down 82 104 83 105 ##@ Utility Targets 84 106
+10
cmd/hold/main.go
··· 179 179 } 180 180 } 181 181 182 + // Request crawl from relay to make PDS discoverable 183 + if cfg.Server.RelayEndpoint != "" { 184 + slog.Info("Requesting crawl from relay", "relay", cfg.Server.RelayEndpoint) 185 + if err := hold.RequestCrawl(cfg.Server.RelayEndpoint, cfg.Server.PublicURL); err != nil { 186 + slog.Warn("Failed to request crawl from relay", "error", err) 187 + } else { 188 + slog.Info("Crawl requested successfully") 189 + } 190 + } 191 + 182 192 // Wait for signal or server error 183 193 select { 184 194 case err := <-serverErr:
+5 -11
deploy/.env.prod.template
··· 115 115 AWS_SECRET_ACCESS_KEY= 116 116 117 117 # S3 Region (for distribution S3 driver) 118 - # UpCloud regions: us-chi1, us-nyc1, de-fra1, uk-lon1, sg-sin1, etc. 119 - # Note: Use AWS_REGION (not S3_REGION) - this is what the hold service expects 118 + # For third-party S3 providers (UpCloud, Storj, Minio), this value is ignored 119 + # when S3_ENDPOINT is set, but must be a valid AWS region to pass validation. 120 120 # Default: us-east-1 121 - AWS_REGION=us-chi1 121 + AWS_REGION=us-east-1 122 122 123 123 # S3 Bucket Name 124 124 # Create this bucket in UpCloud Object Storage ··· 133 133 # NOTE: Use the bucket-specific endpoint, NOT a custom domain 134 134 # Custom domains break presigned URL generation 135 135 S3_ENDPOINT=https://6vmss.upcloudobjects.com 136 - 137 - # S3 Region Endpoint (alternative to S3_ENDPOINT) 138 - # Use this if your S3 driver requires region-specific endpoint format 139 - # Example: s3.us-chi1.upcloudobjects.com 140 - # S3_REGION_ENDPOINT= 141 136 142 137 # ============================================================================== 143 138 # AppView Configuration ··· 231 226 # ☐ Set HOLD_OWNER (your ATProto DID) 232 227 # ☐ Set HOLD_DATABASE_DIR (default: /var/lib/atcr-hold) - enables embedded PDS 233 228 # ☐ Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY 234 - # ☐ Set AWS_REGION (e.g., us-chi1) 235 229 # ☐ Set S3_BUCKET (created in UpCloud Object Storage) 236 - # ☐ Set S3_ENDPOINT (UpCloud endpoint or custom domain) 230 + # ☐ Set S3_ENDPOINT (UpCloud bucket endpoint, e.g., https://6vmss.upcloudobjects.com) 237 231 # ☐ Configured DNS records: 238 232 # - A record: atcr.io → server IP 239 233 # - A record: hold01.atcr.io → server IP 240 - # - CNAME: blobs.atcr.io → [bucket].us-chi1.upcloudobjects.com 234 + # - CNAME: blobs.atcr.io → [bucket].upcloudobjects.com 241 235 # ☐ Disabled Cloudflare proxy (gray cloud, not orange) 242 236 # ☐ Waited for DNS propagation (check with: dig atcr.io) 243 237 #
+1 -6
deploy/docker-compose.prod.yml
··· 109 109 # S3/UpCloud Object Storage configuration 110 110 AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} 111 111 AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} 112 - AWS_REGION: ${AWS_REGION:-us-chi1} 112 + AWS_REGION: ${AWS_REGION:-us-east-1} 113 113 S3_BUCKET: ${S3_BUCKET:-atcr-blobs} 114 114 S3_ENDPOINT: ${S3_ENDPOINT:-} 115 - S3_REGION_ENDPOINT: ${S3_REGION_ENDPOINT:-} 116 115 117 116 # Logging 118 117 ATCR_LOG_LEVEL: ${ATCR_LOG_LEVEL:-debug} ··· 160 159 # Preserve original host header 161 160 header_up Host {host} 162 161 header_up X-Real-IP {remote_host} 163 - header_up X-Forwarded-For {remote_host} 164 - header_up X-Forwarded-Proto {scheme} 165 162 } 166 163 167 164 # Enable compression ··· 183 180 # Preserve original host header 184 181 header_up Host {host} 185 182 header_up X-Real-IP {remote_host} 186 - header_up X-Forwarded-For {remote_host} 187 - header_up X-Forwarded-Proto {scheme} 188 183 } 189 184 190 185 # Enable compression
+1
pkg/appview/jetstream/processor_test.go
··· 70 70 platform_os TEXT, 71 71 platform_variant TEXT, 72 72 platform_os_version TEXT, 73 + is_attestation BOOLEAN DEFAULT FALSE, 73 74 reference_index INTEGER NOT NULL, 74 75 PRIMARY KEY(manifest_id, reference_index) 75 76 );
+39
pkg/appview/templates/components/head.html
··· 2 2 <meta charset="UTF-8"> 3 3 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 4 4 5 + <!-- Open Graph Meta Tags --> 6 + {{ if .ViewedUser }} 7 + <!-- User Profile Page --> 8 + <meta property="og:title" content="{{ .ViewedUser.Handle }} - ATCR"> 9 + <meta property="og:description" content="Container images by {{ .ViewedUser.Handle }} on ATCR"> 10 + {{ if .ViewedUser.Avatar }} 11 + <meta property="og:image" content="{{ .ViewedUser.Avatar }}"> 12 + {{ else }} 13 + <meta property="og:image" content="{{ .RegistryURL }}/web-app-manifest-512x512.png"> 14 + {{ end }} 15 + <meta property="og:type" content="profile"> 16 + <meta property="og:url" content="{{ .RegistryURL }}/u/{{ .ViewedUser.Handle }}"> 17 + {{ else if .Repository }} 18 + <!-- Repository Page --> 19 + <meta property="og:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR"> 20 + {{ if .Repository.Description }} 21 + <meta property="og:description" content="{{ .Repository.Description }}"> 22 + {{ else }} 23 + <meta property="og:description" content="Container image on ATCR"> 24 + {{ end }} 25 + {{ if .Repository.IconURL }} 26 + <meta property="og:image" content="{{ .Repository.IconURL }}"> 27 + {{ else if .Owner.Avatar }} 28 + <meta property="og:image" content="{{ .Owner.Avatar }}"> 29 + {{ else }} 30 + <meta property="og:image" content="{{ .RegistryURL }}/web-app-manifest-512x512.png"> 31 + {{ end }} 32 + <meta property="og:type" content="website"> 33 + <meta property="og:url" content="{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"> 34 + {{ else }} 35 + <!-- Home Page / Default --> 36 + <meta property="og:title" content="ATCR - Distributed Container Registry"> 37 + <meta property="og:description" content="Push and pull Docker images on the AT Protocol"> 38 + <meta property="og:image" content="{{ .RegistryURL }}/web-app-manifest-512x512.png"> 39 + <meta property="og:type" content="website"> 40 + <meta property="og:url" content="{{ .RegistryURL }}"> 41 + {{ end }} 42 + <meta property="og:site_name" content="ATCR"> 43 + 5 44 <!-- Favicons --> 6 45 <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> 7 46 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+54
pkg/hold/config.go
··· 6 6 package hold 7 7 8 8 import ( 9 + "bytes" 10 + "encoding/json" 9 11 "fmt" 12 + "net/http" 13 + "net/url" 10 14 "os" 11 15 "path/filepath" 12 16 "time" ··· 67 71 // DisablePresignedURLs forces proxy mode even with S3 configured (for testing) (from env: DISABLE_PRESIGNED_URLS) 68 72 DisablePresignedURLs bool `yaml:"disable_presigned_urls"` 69 73 74 + // RelayEndpoint is the ATProto relay URL to request crawl from on startup (from env: HOLD_RELAY_ENDPOINT) 75 + // If empty, no crawl request is made. Default: https://bsky.network 76 + RelayEndpoint string `yaml:"relay_endpoint"` 77 + 70 78 // ReadTimeout for HTTP requests 71 79 ReadTimeout time.Duration `yaml:"read_timeout"` 72 80 ··· 103 111 cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true" 104 112 cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" 105 113 cfg.Server.DisablePresignedURLs = os.Getenv("DISABLE_PRESIGNED_URLS") == "true" 114 + cfg.Server.RelayEndpoint = getEnvOrDefault("HOLD_RELAY_ENDPOINT", "https://bsky.network") 106 115 cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads 107 116 cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads 108 117 ··· 180 189 } 181 190 return defaultValue 182 191 } 192 + 193 + // RequestCrawl sends a crawl request to the ATProto relay for the given hostname. 194 + // This makes the hold's PDS discoverable by the relay network. 195 + func RequestCrawl(relayEndpoint, publicURL string) error { 196 + if relayEndpoint == "" { 197 + return nil // No relay configured, skip 198 + } 199 + 200 + // Extract hostname from public URL 201 + parsed, err := url.Parse(publicURL) 202 + if err != nil { 203 + return fmt.Errorf("failed to parse public URL: %w", err) 204 + } 205 + hostname := parsed.Host 206 + 207 + // Build the request URL 208 + requestURL := relayEndpoint + "/xrpc/com.atproto.sync.requestCrawl" 209 + 210 + // Create request body 211 + body := map[string]string{"hostname": hostname} 212 + bodyJSON, err := json.Marshal(body) 213 + if err != nil { 214 + return fmt.Errorf("failed to marshal request body: %w", err) 215 + } 216 + 217 + // Make the request 218 + client := &http.Client{Timeout: 10 * time.Second} 219 + req, err := http.NewRequest("POST", requestURL, bytes.NewReader(bodyJSON)) 220 + if err != nil { 221 + return fmt.Errorf("failed to create request: %w", err) 222 + } 223 + req.Header.Set("Content-Type", "application/json") 224 + 225 + resp, err := client.Do(req) 226 + if err != nil { 227 + return fmt.Errorf("failed to send request: %w", err) 228 + } 229 + defer resp.Body.Close() 230 + 231 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 232 + return fmt.Errorf("relay returned status %d", resp.StatusCode) 233 + } 234 + 235 + return nil 236 + }