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

Configure Feed

Select the types of activity you want to include in your feed.

fix warning when trying to delete a manifest tied to tag. fix download counts counting HEAD requests. fix dropdown not working on settings page

+395 -19
+28
pkg/appview/db/queries.go
··· 1088 1088 return count > 0, nil 1089 1089 } 1090 1090 1091 + // GetManifestTags retrieves all tags for a manifest 1092 + func GetManifestTags(db *sql.DB, did, repository, digest string) ([]string, error) { 1093 + rows, err := db.Query(` 1094 + SELECT tag FROM tags 1095 + WHERE did = ? AND repository = ? AND digest = ? 1096 + ORDER BY tag 1097 + `, did, repository, digest) 1098 + if err != nil { 1099 + return nil, err 1100 + } 1101 + defer rows.Close() 1102 + 1103 + var tags []string 1104 + for rows.Next() { 1105 + var tag string 1106 + if err := rows.Scan(&tag); err != nil { 1107 + return nil, err 1108 + } 1109 + tags = append(tags, tag) 1110 + } 1111 + 1112 + if err := rows.Err(); err != nil { 1113 + return nil, err 1114 + } 1115 + 1116 + return tags, nil 1117 + } 1118 + 1091 1119 // BackfillState represents the backfill progress 1092 1120 type BackfillState struct { 1093 1121 StartCursor int64
+42 -2
pkg/appview/handlers/images.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "encoding/json" 5 6 "fmt" 6 7 "net/http" 7 8 "strings" ··· 75 76 76 77 repo := chi.URLParam(r, "repository") 77 78 digest := chi.URLParam(r, "digest") 79 + confirmed := r.URL.Query().Get("confirm") == "true" 78 80 79 81 // Check if manifest is tagged 80 82 tagged, err := db.IsManifestTagged(h.DB, user.DID, repo, digest) ··· 83 85 return 84 86 } 85 87 86 - if tagged { 87 - http.Error(w, "Cannot delete tagged manifest", http.StatusBadRequest) 88 + // If tagged and not confirmed, return tag list and require confirmation 89 + if tagged && !confirmed { 90 + tags, err := db.GetManifestTags(h.DB, user.DID, repo, digest) 91 + if err != nil { 92 + http.Error(w, err.Error(), http.StatusInternalServerError) 93 + return 94 + } 95 + 96 + w.Header().Set("Content-Type", "application/json") 97 + w.WriteHeader(http.StatusConflict) 98 + json.NewEncoder(w).Encode(map[string]interface{}{ 99 + "error": "confirmation_required", 100 + "message": "This manifest has associated tags that will also be deleted", 101 + "tags": tags, 102 + }) 88 103 return 89 104 } 90 105 ··· 98 113 // Create ATProto client with OAuth credentials 99 114 apiClient := session.APIClient() 100 115 pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) 116 + 117 + // If tagged and confirmed, delete all tags first 118 + if tagged && confirmed { 119 + tags, err := db.GetManifestTags(h.DB, user.DID, repo, digest) 120 + if err != nil { 121 + http.Error(w, fmt.Sprintf("Failed to get tags: %v", err), http.StatusInternalServerError) 122 + return 123 + } 124 + 125 + // Delete each tag from PDS and database 126 + for _, tag := range tags { 127 + // Delete from PDS 128 + tagRKey := fmt.Sprintf("%s:%s", repo, tag) 129 + if err := pdsClient.DeleteRecord(r.Context(), atproto.TagCollection, tagRKey); err != nil { 130 + http.Error(w, fmt.Sprintf("Failed to delete tag '%s' from PDS: %v", tag, err), http.StatusInternalServerError) 131 + return 132 + } 133 + 134 + // Delete from cache 135 + if err := db.DeleteTag(h.DB, user.DID, repo, tag); err != nil { 136 + http.Error(w, fmt.Sprintf("Failed to delete tag '%s' from cache: %v", tag, err), http.StatusInternalServerError) 137 + return 138 + } 139 + } 140 + } 101 141 102 142 // Compute rkey for manifest record (digest without "sha256:" prefix) 103 143 rkey := strings.TrimPrefix(digest, "sha256:")
+111
pkg/appview/static/js/app.js
··· 305 305 } 306 306 } 307 307 }); 308 + 309 + // Delete manifest with confirmation for tagged manifests 310 + async function deleteManifest(repository, digest, sanitizedId) { 311 + try { 312 + // First, try to delete without confirmation 313 + const response = await fetch(`/api/images/${repository}/manifests/${digest}`, { 314 + method: 'DELETE', 315 + credentials: 'include', 316 + }); 317 + 318 + if (response.status === 409) { 319 + // Manifest has tags, need confirmation 320 + const data = await response.json(); 321 + showManifestDeleteModal(repository, digest, sanitizedId, data.tags); 322 + } else if (response.ok) { 323 + // Successfully deleted 324 + removeManifestElement(sanitizedId); 325 + } else { 326 + // Other error 327 + const errorText = await response.text(); 328 + alert(`Failed to delete manifest: ${errorText}`); 329 + } 330 + } catch (err) { 331 + console.error('Error deleting manifest:', err); 332 + alert(`Error deleting manifest: ${err.message}`); 333 + } 334 + } 335 + 336 + // Show the confirmation modal for deleting a tagged manifest 337 + function showManifestDeleteModal(repository, digest, sanitizedId, tags) { 338 + const modal = document.getElementById('manifest-delete-modal'); 339 + const tagsList = document.getElementById('manifest-delete-tags'); 340 + const confirmBtn = document.getElementById('confirm-manifest-delete-btn'); 341 + 342 + // Clear and populate tags list 343 + tagsList.innerHTML = ''; 344 + tags.forEach(tag => { 345 + const li = document.createElement('li'); 346 + li.textContent = tag; 347 + tagsList.appendChild(li); 348 + }); 349 + 350 + // Set up confirm button click handler 351 + confirmBtn.onclick = () => confirmManifestDelete(repository, digest, sanitizedId); 352 + 353 + // Show modal 354 + modal.style.display = 'flex'; 355 + } 356 + 357 + // Close the manifest delete confirmation modal 358 + function closeManifestDeleteModal() { 359 + const modal = document.getElementById('manifest-delete-modal'); 360 + modal.style.display = 'none'; 361 + } 362 + 363 + // Confirm and execute manifest deletion with all tags 364 + async function confirmManifestDelete(repository, digest, sanitizedId) { 365 + const confirmBtn = document.getElementById('confirm-manifest-delete-btn'); 366 + const originalText = confirmBtn.textContent; 367 + 368 + try { 369 + // Disable button and show loading state 370 + confirmBtn.disabled = true; 371 + confirmBtn.textContent = 'Deleting...'; 372 + 373 + // Delete with confirmation 374 + const response = await fetch(`/api/images/${repository}/manifests/${digest}?confirm=true`, { 375 + method: 'DELETE', 376 + credentials: 'include', 377 + }); 378 + 379 + if (response.ok) { 380 + // Successfully deleted 381 + closeManifestDeleteModal(); 382 + removeManifestElement(sanitizedId); 383 + // Also remove any tag elements that were deleted 384 + location.reload(); // Reload to refresh the tags list 385 + } else { 386 + // Error 387 + const errorText = await response.text(); 388 + alert(`Failed to delete manifest: ${errorText}`); 389 + confirmBtn.disabled = false; 390 + confirmBtn.textContent = originalText; 391 + } 392 + } catch (err) { 393 + console.error('Error deleting manifest:', err); 394 + alert(`Error deleting manifest: ${err.message}`); 395 + confirmBtn.disabled = false; 396 + confirmBtn.textContent = originalText; 397 + } 398 + } 399 + 400 + // Remove a manifest element from the DOM 401 + function removeManifestElement(sanitizedId) { 402 + const element = document.getElementById(`manifest-${sanitizedId}`); 403 + if (element) { 404 + element.remove(); 405 + } 406 + } 407 + 408 + // Close modal when clicking outside 409 + document.addEventListener('DOMContentLoaded', () => { 410 + const modal = document.getElementById('manifest-delete-modal'); 411 + if (modal) { 412 + modal.addEventListener('click', (e) => { 413 + if (e.target === modal) { 414 + closeManifestDeleteModal(); 415 + } 416 + }); 417 + } 418 + });
+7 -2
pkg/appview/storage/context_test.go
··· 8 8 ) 9 9 10 10 // Mock implementations for testing 11 - type mockDatabaseMetrics struct{} 11 + type mockDatabaseMetrics struct { 12 + pullCount int 13 + pushCount int 14 + } 12 15 13 16 func (m *mockDatabaseMetrics) IncrementPullCount(did, repository string) error { 17 + m.pullCount++ 14 18 return nil 15 19 } 16 20 17 21 func (m *mockDatabaseMetrics) IncrementPushCount(did, repository string) error { 22 + m.pushCount++ 18 23 return nil 19 24 } 20 25 ··· 96 101 } 97 102 98 103 // Test that interface methods are callable 99 - content, err := ctx.ReadmeCache.Get(nil, "https://example.com/README.md") 104 + content, err := ctx.ReadmeCache.Get(context.Background(), "https://example.com/README.md") 100 105 if err != nil { 101 106 t.Errorf("Unexpected error: %v", err) 102 107 }
+9 -5
pkg/appview/storage/manifest_store.go
··· 86 86 } 87 87 88 88 // Track pull count (increment asynchronously to avoid blocking the response) 89 + // Only count GET requests (actual downloads), not HEAD requests (existence checks) 89 90 if s.ctx.Database != nil { 90 - go func() { 91 - if err := s.ctx.Database.IncrementPullCount(s.ctx.DID, s.ctx.Repository); err != nil { 92 - slog.Warn("Failed to increment pull count", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err) 93 - } 94 - }() 91 + // Check HTTP method from context (distribution library stores it as "http.request.method") 92 + if method, ok := ctx.Value("http.request.method").(string); ok && method == "GET" { 93 + go func() { 94 + if err := s.ctx.Database.IncrementPullCount(s.ctx.DID, s.ctx.Repository); err != nil { 95 + slog.Warn("Failed to increment pull count", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err) 96 + } 97 + }() 98 + } 95 99 } 96 100 97 101 // Parse the manifest based on media type
+77
pkg/appview/storage/manifest_store_test.go
··· 7 7 "net/http" 8 8 "net/http/httptest" 9 9 "testing" 10 + "time" 10 11 11 12 "atcr.io/pkg/atproto" 12 13 "github.com/distribution/distribution/v3" ··· 600 601 gotHoldDID := store.GetLastFetchedHoldDID() 601 602 if gotHoldDID != tt.expectedHoldDID { 602 603 t.Errorf("GetLastFetchedHoldDID() = %v, want %v", gotHoldDID, tt.expectedHoldDID) 604 + } 605 + }) 606 + } 607 + } 608 + 609 + // TestManifestStore_Get_OnlyCountsGETRequests verifies that HEAD requests don't increment pull count 610 + func TestManifestStore_Get_OnlyCountsGETRequests(t *testing.T) { 611 + ociManifest := []byte(`{"schemaVersion":2}`) 612 + 613 + tests := []struct { 614 + name string 615 + httpMethod string 616 + expectPullIncrement bool 617 + }{ 618 + { 619 + name: "GET request increments pull count", 620 + httpMethod: "GET", 621 + expectPullIncrement: true, 622 + }, 623 + { 624 + name: "HEAD request does not increment pull count", 625 + httpMethod: "HEAD", 626 + expectPullIncrement: false, 627 + }, 628 + { 629 + name: "POST request does not increment pull count", 630 + httpMethod: "POST", 631 + expectPullIncrement: false, 632 + }, 633 + } 634 + 635 + for _, tt := range tests { 636 + t.Run(tt.name, func(t *testing.T) { 637 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 638 + if r.URL.Path == atproto.SyncGetBlob { 639 + w.Write(ociManifest) 640 + return 641 + } 642 + w.Write([]byte(`{ 643 + "uri": "at://did:plc:test123/io.atcr.manifest/abc123", 644 + "value": { 645 + "$type":"io.atcr.manifest", 646 + "holdDid":"did:web:hold01.atcr.io", 647 + "mediaType":"application/vnd.oci.image.manifest.v1+json", 648 + "manifestBlob":{"ref":{"$link":"bafytest"},"size":100} 649 + } 650 + }`)) 651 + })) 652 + defer server.Close() 653 + 654 + client := atproto.NewClient(server.URL, "did:plc:test123", "token") 655 + mockDB := &mockDatabaseMetrics{} 656 + ctx := mockRegistryContext(client, "myapp", "did:web:hold01.atcr.io", "did:plc:test123", "test.handle", mockDB) 657 + store := NewManifestStore(ctx, nil) 658 + 659 + // Create a context with the HTTP method stored (as distribution library does) 660 + testCtx := context.WithValue(context.Background(), "http.request.method", tt.httpMethod) 661 + 662 + _, err := store.Get(testCtx, "sha256:abc123") 663 + if err != nil { 664 + t.Fatalf("Get() error = %v", err) 665 + } 666 + 667 + // Wait for async goroutine to complete (metrics are incremented asynchronously) 668 + time.Sleep(50 * time.Millisecond) 669 + 670 + if tt.expectPullIncrement { 671 + // Check that IncrementPullCount was called 672 + if mockDB.pullCount == 0 { 673 + t.Error("Expected pull count to be incremented for GET request, but it wasn't") 674 + } 675 + } else { 676 + // Check that IncrementPullCount was NOT called 677 + if mockDB.pullCount > 0 { 678 + t.Errorf("Expected pull count NOT to be incremented for %s request, but it was (count=%d)", tt.httpMethod, mockDB.pullCount) 679 + } 603 680 } 604 681 }) 605 682 }
+2 -2
pkg/appview/templates/pages/install.html
··· 81 81 <p>You can also use <code>docker login</code> with your ATProto app password:</p> 82 82 83 83 <ol> 84 - <li>Generate an app password in your ATProto account settings</li> 84 + <li>Generate an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank">bsky.app/settings/app-passwords</a></li> 85 85 <li>Run: <code>docker login {{ .RegistryURL }}</code></li> 86 86 <li>Enter your handle as username</li> 87 87 <li>Enter your app password</li> 88 88 </ol> 89 89 90 90 <div class="note"> 91 - <strong>Note:</strong> App passwords are available in your Bluesky account settings under "App Passwords". 91 + <strong>Note:</strong> Create an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank">bsky.app/settings/app-passwords</a>. 92 92 </div> 93 93 </div> 94 94
+118 -4
pkg/appview/templates/pages/repository.html
··· 212 212 </time> 213 213 {{ if $.IsOwner }} 214 214 <button class="delete-btn" 215 - hx-delete="/api/images/{{ $.Repository.Name }}/manifests/{{ .Manifest.Digest }}" 216 - hx-confirm="Delete manifest {{ .Manifest.Digest }}? This cannot be undone." 217 - hx-target="#manifest-{{ sanitizeID .Manifest.Digest }}" 218 - hx-swap="outerHTML"> 215 + onclick="deleteManifest('{{ $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')"> 219 216 🗑️ 220 217 </button> 221 218 {{ end }} ··· 259 256 260 257 <!-- Modal container for HTMX --> 261 258 <div id="modal"></div> 259 + 260 + <!-- Manifest Delete Confirmation Modal --> 261 + <div id="manifest-delete-modal" class="modal-overlay" style="display: none;"> 262 + <div class="modal-dialog"> 263 + <div class="modal-header"> 264 + <h3>Confirm Deletion</h3> 265 + <button class="modal-close" onclick="closeManifestDeleteModal()">&times;</button> 266 + </div> 267 + <div class="modal-body"> 268 + <p id="manifest-delete-message">This manifest has associated tags that will also be deleted:</p> 269 + <ul id="manifest-delete-tags" class="tag-list"></ul> 270 + <p><strong>This action cannot be undone.</strong></p> 271 + </div> 272 + <div class="modal-footer"> 273 + <button class="btn btn-secondary" onclick="closeManifestDeleteModal()">Cancel</button> 274 + <button class="btn btn-danger" id="confirm-manifest-delete-btn">Delete All</button> 275 + </div> 276 + </div> 277 + </div> 278 + 279 + <style> 280 + .modal-overlay { 281 + position: fixed; 282 + top: 0; 283 + left: 0; 284 + width: 100%; 285 + height: 100%; 286 + background: rgba(0, 0, 0, 0.5); 287 + display: flex; 288 + align-items: center; 289 + justify-content: center; 290 + z-index: 1000; 291 + } 292 + 293 + .modal-dialog { 294 + background: var(--bg-secondary, #1a1a1a); 295 + border: 1px solid var(--border-color, #333); 296 + border-radius: 8px; 297 + max-width: 500px; 298 + width: 90%; 299 + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); 300 + } 301 + 302 + .modal-header { 303 + padding: 1rem 1.5rem; 304 + border-bottom: 1px solid var(--border-color, #333); 305 + display: flex; 306 + justify-content: space-between; 307 + align-items: center; 308 + } 309 + 310 + .modal-header h3 { 311 + margin: 0; 312 + font-size: 1.25rem; 313 + } 314 + 315 + .modal-close { 316 + background: none; 317 + border: none; 318 + font-size: 1.5rem; 319 + cursor: pointer; 320 + color: var(--text-color, #fff); 321 + padding: 0; 322 + width: 2rem; 323 + height: 2rem; 324 + line-height: 1; 325 + } 326 + 327 + .modal-body { 328 + padding: 1.5rem; 329 + } 330 + 331 + .modal-body .tag-list { 332 + margin: 1rem 0; 333 + padding-left: 1.5rem; 334 + } 335 + 336 + .modal-body .tag-list li { 337 + margin: 0.5rem 0; 338 + font-family: monospace; 339 + } 340 + 341 + .modal-footer { 342 + padding: 1rem 1.5rem; 343 + border-top: 1px solid var(--border-color, #333); 344 + display: flex; 345 + justify-content: flex-end; 346 + gap: 0.5rem; 347 + } 348 + 349 + .btn { 350 + padding: 0.5rem 1rem; 351 + border: none; 352 + border-radius: 4px; 353 + cursor: pointer; 354 + font-size: 0.875rem; 355 + font-weight: 500; 356 + } 357 + 358 + .btn-secondary { 359 + background: var(--bg-tertiary, #2a2a2a); 360 + color: var(--text-color, #fff); 361 + } 362 + 363 + .btn-secondary:hover { 364 + background: var(--bg-hover, #3a3a3a); 365 + } 366 + 367 + .btn-danger { 368 + background: #dc3545; 369 + color: white; 370 + } 371 + 372 + .btn-danger:hover { 373 + background: #c82333; 374 + } 375 + </style> 262 376 </body> 263 377 </html> 264 378 {{ end }}
+1 -4
pkg/appview/templates/pages/settings.html
··· 83 83 </ol> 84 84 85 85 <div class="fallback-note"> 86 - <strong>Fallback:</strong> Use app-password with <code>docker login {{ .RegistryURL }}</code> for quick start (no device tracking) 86 + <strong>Fallback:</strong> Use <a href="https://bsky.app/settings/app-passwords" target="_blank">app password</a> with <code>docker login {{ .RegistryURL }}</code> for quick start (no device tracking) 87 87 </div> 88 88 </div> 89 89 ··· 108 108 </section> 109 109 </div> 110 110 </main> 111 - 112 - 113 - <script src="/js/app.js"></script> 114 111 115 112 <script> 116 113 // Default Hold Update - Dynamic display update