A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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