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

add hero banner, fix up css styles

evan.jarrett.net 6359edaf 64a05d40

verified
+423 -109
-2
CLAUDE.md
··· 492 492 493 493 **OAuth implementation:** 494 494 - Client (`pkg/auth/oauth/client.go`) encapsulates all OAuth configuration 495 - - Uses `authelia.com/client/oauth2` for PAR support 496 - - DPoP proofs generated with `github.com/AxisCommunications/go-dpop` (auto-handles JWK) 497 495 - Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity 498 496 - All ATCR components use standardized `/auth/oauth/callback` path 499 497 - Client ID generation (localhost query-based vs production metadata URL) handled internally
+2 -2
deploy/README.md
··· 466 466 ## Support 467 467 468 468 - Documentation: https://tangled.org/@evan.jarrett.net/at-container-registry 469 - - Issues: https://github.com/your-org/atcr.io/issues 470 - - Bluesky: @yourhandle.bsky.social 469 + - Issues: https://tangled.org/@evan.jarrett.net/at-container-registry/issues 470 + - Bluesky: @evan.jarrett.net
+1 -1
pkg/appview/handlers/home.go
··· 60 60 } 61 61 62 62 func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 63 - limit := 50 63 + limit := 20 64 64 offset := 0 65 65 66 66 if o := r.URL.Query().Get("offset"); o != "" {
+316 -13
pkg/appview/static/css/style.css
··· 1 1 :root { 2 2 --primary: #0066cc; 3 + --primary-dark: #0052a3; 3 4 --secondary: #6c757d; 4 5 --success: #28a745; 6 + --success-bg: #d4edda; 7 + --warning: #ffc107; 8 + --warning-bg: #fff3cd; 5 9 --danger: #dc3545; 10 + --danger-bg: #f8d7da; 6 11 --bg: #ffffff; 7 12 --fg: #1a1a1a; 8 13 --border-dark: #666; ··· 10 15 --code-bg: #f5f5f5; 11 16 --hover-bg: #f9f9f9; 12 17 --star: #fbbf24; 18 + 19 + /* Hero section colors */ 20 + --hero-bg-start: #f8f9fa; 21 + --hero-bg-end: #e9ecef; 22 + 23 + /* Terminal colors */ 24 + --terminal-bg: var(--fg); 25 + --terminal-header-bg: #2d2d2d; 26 + --terminal-text: var(--border); 27 + --terminal-prompt: #4ec9b0; 28 + --terminal-comment: #6a9955; 13 29 } 14 30 15 31 * { ··· 694 710 padding: 1rem; 695 711 } 696 712 697 - /* Status Messages */ 713 + /* Status Messages / Callouts */ 714 + .note { 715 + background: var(--warning-bg); 716 + border-left: 4px solid var(--warning); 717 + padding: 1rem; 718 + margin: 1rem 0; 719 + } 720 + 698 721 .success { 699 - color: var(--success); 700 - padding: 0.5rem; 701 - background: #d4edda; 702 - border: 1px solid #c3e6cb; 703 - border-radius: 4px; 704 - margin-top: 1rem; 722 + background: var(--success-bg); 723 + border-left: 4px solid var(--success); 724 + padding: 1rem; 725 + margin: 1rem 0; 705 726 } 706 727 707 728 .error { 708 - color: var(--danger); 709 - padding: 0.5rem; 710 - background: #f8d7da; 711 - border: 1px solid #f5c6cb; 712 - border-radius: 4px; 713 - margin-top: 1rem; 729 + background: var(--danger-bg); 730 + border-left: 4px solid var(--danger); 731 + padding: 1rem; 732 + margin: 1rem 0; 714 733 } 715 734 716 735 /* Load More Button */ ··· 1167 1186 color: var(--fg); 1168 1187 } 1169 1188 1189 + /* Hero Section */ 1190 + .hero-section { 1191 + background: linear-gradient(135deg, var(--hero-bg-start) 0%, var(--hero-bg-end) 100%); 1192 + padding: 4rem 2rem; 1193 + border-bottom: 1px solid var(--border); 1194 + } 1195 + 1196 + .hero-content { 1197 + max-width: 900px; 1198 + margin: 0 auto; 1199 + text-align: center; 1200 + } 1201 + 1202 + .hero-title { 1203 + font-size: 3rem; 1204 + font-weight: 700; 1205 + margin-bottom: 1.5rem; 1206 + color: var(--fg); 1207 + line-height: 1.2; 1208 + } 1209 + 1210 + .hero-subtitle { 1211 + font-size: 1.2rem; 1212 + color: var(--border-dark); 1213 + margin-bottom: 3rem; 1214 + line-height: 1.6; 1215 + } 1216 + 1217 + .hero-terminal { 1218 + max-width: 600px; 1219 + margin: 0 auto 2.5rem; 1220 + background: var(--terminal-bg); 1221 + border-radius: 8px; 1222 + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); 1223 + overflow: hidden; 1224 + } 1225 + 1226 + .terminal-header { 1227 + background: var(--terminal-header-bg); 1228 + padding: 0.75rem 1rem; 1229 + display: flex; 1230 + gap: 0.5rem; 1231 + align-items: center; 1232 + } 1233 + 1234 + .terminal-dot { 1235 + width: 12px; 1236 + height: 12px; 1237 + border-radius: 50%; 1238 + background: var(--border-dark); 1239 + } 1240 + 1241 + .terminal-dot:nth-child(1) { 1242 + background: #ff5f56; 1243 + } 1244 + 1245 + .terminal-dot:nth-child(2) { 1246 + background: #ffbd2e; 1247 + } 1248 + 1249 + .terminal-dot:nth-child(3) { 1250 + background: #27c93f; 1251 + } 1252 + 1253 + .terminal-content { 1254 + padding: 1.5rem; 1255 + margin: 0; 1256 + font-family: 'Monaco', 'Courier New', monospace; 1257 + font-size: 0.95rem; 1258 + line-height: 1.8; 1259 + color: var(--terminal-text); 1260 + overflow-x: auto; 1261 + } 1262 + 1263 + .terminal-prompt { 1264 + color: var(--terminal-prompt); 1265 + font-weight: bold; 1266 + } 1267 + 1268 + .terminal-comment { 1269 + color: var(--terminal-comment); 1270 + font-style: italic; 1271 + } 1272 + 1273 + .hero-actions { 1274 + display: flex; 1275 + gap: 1rem; 1276 + justify-content: center; 1277 + margin-bottom: 4rem; 1278 + } 1279 + 1280 + .btn-hero-primary, 1281 + .btn-hero-secondary { 1282 + padding: 0.9rem 2rem; 1283 + font-size: 1.1rem; 1284 + font-weight: 600; 1285 + border-radius: 6px; 1286 + text-decoration: none; 1287 + transition: all 0.2s ease; 1288 + display: inline-block; 1289 + } 1290 + 1291 + .btn-hero-primary { 1292 + background: var(--primary); 1293 + color: var(--bg); 1294 + border: 2px solid var(--primary); 1295 + } 1296 + 1297 + .btn-hero-primary:hover { 1298 + background: var(--primary-dark); 1299 + border-color: var(--primary-dark); 1300 + transform: translateY(-2px); 1301 + box-shadow: 0 4px 12px rgba(0, 102, 204, 0.3); 1302 + } 1303 + 1304 + .btn-hero-secondary { 1305 + background: transparent; 1306 + color: var(--primary); 1307 + border: 2px solid var(--primary); 1308 + } 1309 + 1310 + .btn-hero-secondary:hover { 1311 + background: var(--primary); 1312 + color: var(--bg); 1313 + transform: translateY(-2px); 1314 + } 1315 + 1316 + .hero-benefits { 1317 + max-width: 1000px; 1318 + margin: 0 auto; 1319 + display: grid; 1320 + grid-template-columns: repeat(3, 1fr); 1321 + gap: 2rem; 1322 + } 1323 + 1324 + .benefit-card { 1325 + background: var(--bg); 1326 + border: 1px solid var(--border); 1327 + border-radius: 8px; 1328 + padding: 2rem 1.5rem; 1329 + text-align: center; 1330 + transition: all 0.2s ease; 1331 + } 1332 + 1333 + .benefit-card:hover { 1334 + border-color: var(--primary); 1335 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); 1336 + transform: translateY(-4px); 1337 + } 1338 + 1339 + .benefit-icon { 1340 + font-size: 3rem; 1341 + margin-bottom: 1rem; 1342 + line-height: 1; 1343 + } 1344 + 1345 + .benefit-card h3 { 1346 + font-size: 1.2rem; 1347 + margin-bottom: 0.75rem; 1348 + color: var(--fg); 1349 + } 1350 + 1351 + .benefit-card p { 1352 + color: var(--border-dark); 1353 + font-size: 0.95rem; 1354 + line-height: 1.5; 1355 + margin: 0; 1356 + } 1357 + 1358 + /* Install Page */ 1359 + .install-page { 1360 + max-width: 800px; 1361 + margin: 0 auto; 1362 + padding: 2rem 1rem; 1363 + } 1364 + 1365 + .install-section { 1366 + margin: 2rem 0; 1367 + } 1368 + 1369 + .install-section h2 { 1370 + margin-bottom: 1rem; 1371 + color: var(--fg); 1372 + } 1373 + 1374 + .install-section h3 { 1375 + margin: 1.5rem 0 0.5rem; 1376 + color: var(--border-dark); 1377 + font-size: 1.1rem; 1378 + } 1379 + 1380 + .code-block { 1381 + background: var(--code-bg); 1382 + border: 1px solid var(--border); 1383 + border-radius: 4px; 1384 + padding: 1rem; 1385 + margin: 0.5rem 0 1rem; 1386 + overflow-x: auto; 1387 + } 1388 + 1389 + .code-block code { 1390 + font-family: 'Monaco', 'Menlo', monospace; 1391 + font-size: 0.9rem; 1392 + line-height: 1.5; 1393 + white-space: pre-wrap; 1394 + } 1395 + 1396 + .platform-tabs { 1397 + display: flex; 1398 + gap: 0.5rem; 1399 + border-bottom: 2px solid var(--border); 1400 + margin-bottom: 1rem; 1401 + } 1402 + 1403 + .platform-tab { 1404 + padding: 0.5rem 1rem; 1405 + cursor: pointer; 1406 + border: none; 1407 + background: none; 1408 + font-size: 1rem; 1409 + color: var(--border-dark); 1410 + transition: all 0.2s; 1411 + } 1412 + 1413 + .platform-tab:hover { 1414 + color: var(--fg); 1415 + } 1416 + 1417 + .platform-tab.active { 1418 + color: var(--primary); 1419 + border-bottom: 2px solid var(--primary); 1420 + margin-bottom: -2px; 1421 + } 1422 + 1423 + .platform-content { 1424 + display: none; 1425 + } 1426 + 1427 + .platform-content.active { 1428 + display: block; 1429 + } 1430 + 1170 1431 /* Responsive */ 1171 1432 @media (max-width: 768px) { 1172 1433 .navbar { ··· 1219 1480 .featured-card { 1220 1481 min-height: auto; 1221 1482 } 1483 + 1484 + .hero-section { 1485 + padding: 3rem 1.5rem; 1486 + } 1487 + 1488 + .hero-title { 1489 + font-size: 2rem; 1490 + } 1491 + 1492 + .hero-subtitle { 1493 + font-size: 1rem; 1494 + margin-bottom: 2rem; 1495 + } 1496 + 1497 + .hero-terminal { 1498 + margin-bottom: 2rem; 1499 + } 1500 + 1501 + .terminal-content { 1502 + font-size: 0.85rem; 1503 + padding: 1rem; 1504 + } 1505 + 1506 + .hero-actions { 1507 + flex-direction: column; 1508 + margin-bottom: 3rem; 1509 + } 1510 + 1511 + .btn-hero-primary, 1512 + .btn-hero-secondary { 1513 + width: 100%; 1514 + text-align: center; 1515 + } 1516 + 1517 + .hero-benefits { 1518 + grid-template-columns: 1fr; 1519 + gap: 1.5rem; 1520 + } 1222 1521 } 1223 1522 1224 1523 @media (max-width: 1024px) and (min-width: 769px) { 1225 1524 .featured-grid { 1226 1525 grid-template-columns: repeat(2, 1fr); 1526 + } 1527 + 1528 + .hero-benefits { 1529 + grid-template-columns: repeat(3, 1fr); 1227 1530 } 1228 1531 }
+49
pkg/appview/templates/pages/home.html
··· 11 11 <body> 12 12 {{ template "nav" . }} 13 13 14 + {{ if not .User }} 15 + <!-- Hero Section for Non-Logged-In Users --> 16 + <section class="hero-section"> 17 + <div class="hero-content"> 18 + <h1 class="hero-title">ship containers on the open web.</h1> 19 + <p class="hero-subtitle"> 20 + Push and pull Docker images on the AT Protocol.<br> 21 + Browse public registries or control your data. 22 + </p> 23 + 24 + <div class="hero-terminal"> 25 + <div class="terminal-header"> 26 + <span class="terminal-dot"></span> 27 + <span class="terminal-dot"></span> 28 + <span class="terminal-dot"></span> 29 + </div> 30 + <pre class="terminal-content"><span class="terminal-prompt">$</span> docker login atcr.io 31 + <span class="terminal-prompt">$</span> docker push atcr.io/you/app 32 + 33 + <span class="terminal-comment"># same docker, decentralized</span></pre> 34 + </div> 35 + 36 + <div class="hero-actions"> 37 + <a href="/auth/oauth/login?return_to=/" class="btn-hero-primary">Get Started</a> 38 + <a href="/install" class="btn-hero-secondary">Learn More</a> 39 + </div> 40 + </div> 41 + 42 + <!-- Benefit Cards --> 43 + <div class="hero-benefits"> 44 + <div class="benefit-card"> 45 + <div class="benefit-icon">🐳</div> 46 + <h3>Works with Docker</h3> 47 + <p>Use docker push & pull. No new tools to learn.</p> 48 + </div> 49 + <div class="benefit-card"> 50 + <div class="benefit-icon">⚓</div> 51 + <h3>Your Data</h3> 52 + <p>Join shared holds or captain your own storage.</p> 53 + </div> 54 + <div class="benefit-card"> 55 + <div class="benefit-icon">🧭</div> 56 + <h3>Discover Images</h3> 57 + <p>Browse and star public container registries.</p> 58 + </div> 59 + </div> 60 + </section> 61 + {{ end }} 62 + 14 63 <main class="container"> 15 64 <div class="home-page"> 16 65 <!-- Featured Repositories Section -->
+2 -80
pkg/appview/templates/pages/install.html
··· 7 7 <title>Install ATCR Credential Helper - ATCR</title> 8 8 <link rel="stylesheet" href="/static/css/style.css"> 9 9 <script src="https://unpkg.com/htmx.org@1.9.10"></script> 10 - <style> 11 - .install-page { 12 - max-width: 800px; 13 - margin: 0 auto; 14 - padding: 2rem 1rem; 15 - } 16 - .install-section { 17 - margin: 2rem 0; 18 - } 19 - .install-section h2 { 20 - margin-bottom: 1rem; 21 - color: #1a1a1a; 22 - } 23 - .install-section h3 { 24 - margin: 1.5rem 0 0.5rem; 25 - color: #4a4a4a; 26 - font-size: 1.1rem; 27 - } 28 - .code-block { 29 - background: #f5f5f5; 30 - border: 1px solid #ddd; 31 - border-radius: 4px; 32 - padding: 1rem; 33 - margin: 0.5rem 0 1rem; 34 - overflow-x: auto; 35 - } 36 - .code-block code { 37 - font-family: 'Monaco', 'Menlo', monospace; 38 - font-size: 0.9rem; 39 - line-height: 1.5; 40 - } 41 - .platform-tabs { 42 - display: flex; 43 - gap: 0.5rem; 44 - border-bottom: 2px solid #e0e0e0; 45 - margin-bottom: 1rem; 46 - } 47 - .platform-tab { 48 - padding: 0.5rem 1rem; 49 - cursor: pointer; 50 - border: none; 51 - background: none; 52 - font-size: 1rem; 53 - color: #666; 54 - transition: all 0.2s; 55 - } 56 - .platform-tab:hover { 57 - color: #000; 58 - } 59 - .platform-tab.active { 60 - color: #0066cc; 61 - border-bottom: 2px solid #0066cc; 62 - margin-bottom: -2px; 63 - } 64 - .platform-content { 65 - display: none; 66 - } 67 - .platform-content.active { 68 - display: block; 69 - } 70 - .note { 71 - background: #fff3cd; 72 - border-left: 4px solid #ffc107; 73 - padding: 1rem; 74 - margin: 1rem 0; 75 - } 76 - .success { 77 - background: #d4edda; 78 - border-left: 4px solid #28a745; 79 - padding: 1rem; 80 - margin: 1rem 0; 81 - } 82 - </style> 83 10 </head> 84 11 <body> 85 12 {{ template "nav" . }} ··· 137 64 <h2>Authentication</h2> 138 65 <p>The credential helper will automatically prompt for authentication when you push or pull:</p> 139 66 140 - <div class="code-block"><code>export ATCR_AUTO_AUTH=1 141 - docker push {{ .RegistryURL }}/yourhandle/myapp:latest</code></div> 67 + <div class="code-block"><code>docker push {{ .RegistryURL }}/yourhandle/myapp:latest</code></div> 142 68 143 69 <p>This will:</p> 144 70 <ol> ··· 180 106 # Add to PATH if needed 181 107 export PATH="/usr/local/bin:$PATH"</code></div> 182 108 183 - <h3>Authentication failed</h3> 184 - <p>Make sure auto-auth is enabled:</p> 185 - <div class="code-block"><code>export ATCR_AUTO_AUTH=1</code></div> 186 - 187 109 <h3>Still having issues?</h3> 188 - <p>Check the <a href="https://github.com/atcr-io/atcr/blob/main/INSTALLATION.md">full documentation</a> or <a href="https://github.com/atcr-io/atcr/issues">open an issue</a>.</p> 110 + <p>Check the <a href="https://tangled.org/@evan.jarrett.net/at-container-registry/blob/main/INSTALLATION.md">full documentation</a> or <a href="https://tangled.org/@evan.jarrett.net/at-container-registry/issues">open an issue</a>.</p> 189 111 </div> 190 112 191 113 <div class="install-section">
+16
pkg/hold/authorization.go
··· 106 106 return false, fmt.Errorf("no PDS endpoint found for owner") 107 107 } 108 108 109 + // Build this hold's URI for filtering 110 + publicURL := s.config.Server.PublicURL 111 + if publicURL == "" { 112 + return false, fmt.Errorf("hold public URL not configured") 113 + } 114 + holdName, err := extractHostname(publicURL) 115 + if err != nil { 116 + return false, fmt.Errorf("failed to extract hold name: %w", err) 117 + } 118 + holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName) 119 + 109 120 // Create unauthenticated client to read public records 110 121 client := atproto.NewClient(pdsEndpoint, ownerDID, "") 111 122 ··· 124 135 for _, record := range records { 125 136 var crewRecord atproto.HoldCrewRecord 126 137 if err := json.Unmarshal(record.Value, &crewRecord); err != nil { 138 + continue 139 + } 140 + 141 + // Only check crew records for THIS hold (prevents cross-hold access) 142 + if crewRecord.Hold != holdURI { 127 143 continue 128 144 } 129 145
+37 -11
pkg/hold/registration.go
··· 256 256 return nil 257 257 } 258 258 259 - // hasAllowAllCrewRecord checks if the allow-all crew record exists in the PDS 259 + // hasAllowAllCrewRecord checks if the allow-all crew record exists in the PDS for THIS hold 260 260 func (s *HoldService) hasAllowAllCrewRecord() (bool, error) { 261 261 ownerDID := s.config.Registration.OwnerDID 262 + publicURL := s.config.Server.PublicURL 262 263 if ownerDID == "" { 263 264 return false, fmt.Errorf("hold owner DID not configured") 265 + } 266 + if publicURL == "" { 267 + return false, fmt.Errorf("hold public URL not configured") 264 268 } 265 269 266 270 ctx := context.Background() ··· 282 286 return false, fmt.Errorf("no PDS endpoint found for owner") 283 287 } 284 288 289 + // Build hold-specific rkey 290 + holdName, err := extractHostname(publicURL) 291 + if err != nil { 292 + return false, fmt.Errorf("failed to extract hostname: %w", err) 293 + } 294 + crewRKey := fmt.Sprintf("allow-all-%s", holdName) 295 + 285 296 // Create unauthenticated client to read public records 286 297 client := atproto.NewClient(pdsEndpoint, ownerDID, "") 287 298 288 - // Query for specific rkey "allow-all" 289 - record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, "allow-all") 299 + // Query for hold-specific allow-all record 300 + record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, crewRKey) 290 301 if err != nil { 291 302 // Record doesn't exist 292 303 if errors.Is(err, atproto.ErrRecordNotFound) { ··· 302 313 } 303 314 304 315 // Check if it's the exact wildcard pattern 305 - return crewRecord.MemberPattern != nil && *crewRecord.MemberPattern == "*", nil 316 + if crewRecord.MemberPattern == nil || *crewRecord.MemberPattern != "*" { 317 + return false, nil 318 + } 319 + 320 + // Verify it's for this hold (defensive check) 321 + expectedHoldURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName) 322 + return crewRecord.Hold == expectedHoldURI, nil 306 323 } 307 324 308 325 // createAllowAllCrewRecord creates a wildcard crew record allowing all authenticated users ··· 329 346 // Create wildcard crew record 330 347 crewRecord := atproto.NewHoldCrewRecordWithPattern(holdURI, "*", "write") 331 348 332 - _, err = client.PutRecord(ctx, atproto.HoldCrewCollection, "allow-all", crewRecord) 349 + // Use hold-specific rkey to support multiple holds with different allow-all settings 350 + crewRKey := fmt.Sprintf("allow-all-%s", holdName) 351 + _, err = client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord) 333 352 if err != nil { 334 353 return fmt.Errorf("failed to create allow-all crew record: %w", err) 335 354 } ··· 338 357 return nil 339 358 } 340 359 341 - // deleteAllowAllCrewRecord deletes the wildcard crew record 360 + // deleteAllowAllCrewRecord deletes the wildcard crew record for this hold 342 361 func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error { 343 - // Safety check: only delete if it's the exact wildcard pattern 362 + // Safety check: only delete if it's the exact wildcard pattern for THIS hold 344 363 isWildcard, err := s.hasAllowAllCrewRecord() 345 364 if err != nil { 346 365 return fmt.Errorf("failed to check allow-all crew record: %w", err) 347 366 } 348 367 349 368 if !isWildcard { 350 - log.Printf("Warning: 'allow-all' crew record exists but is not wildcard - skipping deletion") 369 + log.Printf("Note: 'allow-all' crew record not found for this hold (may exist for other holds)") 351 370 return nil 352 371 } 353 372 373 + // Get hold name for rkey 374 + holdName, err := extractHostname(s.config.Server.PublicURL) 375 + if err != nil { 376 + return fmt.Errorf("failed to extract hostname: %w", err) 377 + } 378 + crewRKey := fmt.Sprintf("allow-all-%s", holdName) 379 + 354 380 // Run OAuth flow to get authenticated client 355 381 client, err := s.runOAuthFlow(callbackHandler, "Deleting allow-all crew record") 356 382 if err != nil { ··· 359 385 360 386 ctx := context.Background() 361 387 362 - // Delete the record 363 - err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, "allow-all") 388 + // Delete the hold-specific allow-all record 389 + err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, crewRKey) 364 390 if err != nil { 365 391 return fmt.Errorf("failed to delete allow-all crew record: %w", err) 366 392 } 367 393 368 - log.Printf("✓ Deleted allow-all crew record") 394 + log.Printf("✓ Deleted allow-all crew record for this hold") 369 395 return nil 370 396 } 371 397