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.

refactor how annotations are stored. add ability to create bsky profile for hold user

+1080 -693
+9 -2
cmd/hold/main.go
··· 43 43 log.Fatalf("Failed to initialize embedded PDS: %v", err) 44 44 } 45 45 46 - // Bootstrap PDS with captain record and hold owner as first crew member 47 - if err := holdPDS.Bootstrap(ctx, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew); err != nil { 46 + // Create storage driver from config (needed for bootstrap profile avatar) 47 + driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters()) 48 + if err != nil { 49 + log.Fatalf("failed to create storage driver: %v", err) 50 + return 51 + } 52 + 53 + // Bootstrap PDS with captain record, hold owner as first crew member, and profile 54 + if err := holdPDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL); err != nil { 48 55 log.Fatalf("Failed to bootstrap PDS: %v", err) 49 56 } 50 57
+1 -127
docs/ANNOTATIONS_REFACTOR.md
··· 61 61 62 62 ## Migration Strategy 63 63 64 - ### Migration File: `0004_refactor_annotations_table.yaml` 65 - 66 - ```yaml 67 - description: Migrate manifest annotations to separate table 68 - query: | 69 - -- Step 1: Create new annotations table 70 - CREATE TABLE IF NOT EXISTS repository_annotations ( 71 - did TEXT NOT NULL, 72 - repository TEXT NOT NULL, 73 - key TEXT NOT NULL, 74 - value TEXT NOT NULL, 75 - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 76 - PRIMARY KEY(did, repository, key), 77 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 78 - ); 79 - CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository); 80 - CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key); 81 - 82 - -- Step 2: Migrate existing data from manifests to annotations 83 - -- For each repository, use the most recent manifest with non-empty data 84 - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) 85 - SELECT 86 - m.did, 87 - m.repository, 88 - 'org.opencontainers.image.title' as key, 89 - m.title as value, 90 - m.created_at as updated_at 91 - FROM manifests m 92 - WHERE m.title IS NOT NULL AND m.title != '' 93 - AND m.created_at = ( 94 - SELECT MAX(created_at) FROM manifests m2 95 - WHERE m2.did = m.did AND m2.repository = m.repository 96 - AND m2.title IS NOT NULL AND m2.title != '' 97 - ); 98 - 99 - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) 100 - SELECT m.did, m.repository, 'org.opencontainers.image.description', m.description, m.created_at 101 - FROM manifests m 102 - WHERE m.description IS NOT NULL AND m.description != '' 103 - AND m.created_at = ( 104 - SELECT MAX(created_at) FROM manifests m2 105 - WHERE m2.did = m.did AND m2.repository = m.repository 106 - AND m2.description IS NOT NULL AND m2.description != '' 107 - ); 108 - 109 - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) 110 - SELECT m.did, m.repository, 'org.opencontainers.image.source', m.source_url, m.created_at 111 - FROM manifests m 112 - WHERE m.source_url IS NOT NULL AND m.source_url != '' 113 - AND m.created_at = ( 114 - SELECT MAX(created_at) FROM manifests m2 115 - WHERE m2.did = m.did AND m2.repository = m.repository 116 - AND m2.source_url IS NOT NULL AND m2.source_url != '' 117 - ); 118 - 119 - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) 120 - SELECT m.did, m.repository, 'org.opencontainers.image.documentation', m.documentation_url, m.created_at 121 - FROM manifests m 122 - WHERE m.documentation_url IS NOT NULL AND m.documentation_url != '' 123 - AND m.created_at = ( 124 - SELECT MAX(created_at) FROM manifests m2 125 - WHERE m2.did = m.did AND m2.repository = m.repository 126 - AND m2.documentation_url IS NOT NULL AND m2.documentation_url != '' 127 - ); 128 - 129 - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) 130 - SELECT m.did, m.repository, 'org.opencontainers.image.licenses', m.licenses, m.created_at 131 - FROM manifests m 132 - WHERE m.licenses IS NOT NULL AND m.licenses != '' 133 - AND m.created_at = ( 134 - SELECT MAX(created_at) FROM manifests m2 135 - WHERE m2.did = m.did AND m2.repository = m.repository 136 - AND m2.licenses IS NOT NULL AND m2.licenses != '' 137 - ); 138 - 139 - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) 140 - SELECT m.did, m.repository, 'io.atcr.icon', m.icon_url, m.created_at 141 - FROM manifests m 142 - WHERE m.icon_url IS NOT NULL AND m.icon_url != '' 143 - AND m.created_at = ( 144 - SELECT MAX(created_at) FROM manifests m2 145 - WHERE m2.did = m.did AND m2.repository = m.repository 146 - AND m2.icon_url IS NOT NULL AND m2.icon_url != '' 147 - ); 148 - 149 - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) 150 - SELECT m.did, m.repository, 'io.atcr.readme', m.readme_url, m.created_at 151 - FROM manifests m 152 - WHERE m.readme_url IS NOT NULL AND m.readme_url != '' 153 - AND m.created_at = ( 154 - SELECT MAX(created_at) FROM manifests m2 155 - WHERE m2.did = m.did AND m2.repository = m.repository 156 - AND m2.readme_url IS NOT NULL AND m2.readme_url != '' 157 - ); 158 - 159 - -- Step 3: Drop old columns from manifests table 160 - -- SQLite requires recreating table to drop columns 161 - CREATE TABLE manifests_new ( 162 - id INTEGER PRIMARY KEY AUTOINCREMENT, 163 - did TEXT NOT NULL, 164 - repository TEXT NOT NULL, 165 - digest TEXT NOT NULL, 166 - hold_endpoint TEXT NOT NULL, 167 - schema_version INTEGER NOT NULL, 168 - media_type TEXT NOT NULL, 169 - config_digest TEXT, 170 - config_size INTEGER, 171 - created_at TIMESTAMP NOT NULL, 172 - UNIQUE(did, repository, digest), 173 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 174 - ); 175 - 176 - -- Copy data to new table 177 - INSERT INTO manifests_new 178 - SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, 179 - config_digest, config_size, created_at 180 - FROM manifests; 181 - 182 - -- Replace old table 183 - DROP TABLE manifests; 184 - ALTER TABLE manifests_new RENAME TO manifests; 185 - 186 - -- Recreate indexes 187 - CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 188 - CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 189 - CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); 190 - ``` 64 + There is no need to migrate data to this new table via sql. on startup, backfill will re-populate the new table with existing annotations. 191 65 192 66 ## Code Changes 193 67
+218
docs/HOLD_ENDPOINT_TESTS.md
··· 1 + # Hold Service Endpoint Testing Guide 2 + 3 + ## Quick Reference 4 + 5 + Your hold service: `http://172.28.0.3:8080` 6 + 7 + Default DID format for local testing: `did:web:172.28.0.3%3A8080` (URL-encoded `did:web:172.28.0.3:8080`) 8 + 9 + ## Individual cURL Commands 10 + 11 + ### 1. List Repositories 12 + ```bash 13 + curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.listRepos" | jq . 14 + ``` 15 + 16 + **Expected response:** 17 + ```json 18 + { 19 + "repos": [ 20 + { 21 + "did": "did:web:172.28.0.3%3A8080", 22 + "head": "...", 23 + "rev": "..." 24 + } 25 + ] 26 + } 27 + ``` 28 + 29 + ### 2. Describe Repository 30 + ```bash 31 + curl -s "http://172.28.0.3:8080/xrpc/com.atproto.repo.describeRepo?repo=did:web:172.28.0.3%3A8080" | jq . 32 + ``` 33 + 34 + **Expected response:** 35 + ```json 36 + { 37 + "did": "did:web:172.28.0.3%3A8080", 38 + "handle": "172.28.0.3:8080", 39 + "didDoc": {...}, 40 + "collections": ["io.atcr.hold.captain", "io.atcr.hold.crew"] 41 + } 42 + ``` 43 + 44 + ### 3. Get Repository (CAR file) 45 + ```bash 46 + # Download entire repo as CAR file 47 + curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.getRepo?did=did:web:172.28.0.3%3A8080" -o repo.car 48 + 49 + # Get repo diff since revision 50 + curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.getRepo?did=did:web:172.28.0.3%3A8080&since=abc123" -o repo-diff.car 51 + ``` 52 + 53 + **Expected response:** Binary CAR (Content Addressable aRchive) file 54 + 55 + ### 4. List Captain Records 56 + ```bash 57 + curl -s "http://172.28.0.3:8080/xrpc/com.atproto.repo.listRecords?repo=did:web:172.28.0.3%3A8080&collection=io.atcr.hold.captain" | jq . 58 + ``` 59 + 60 + **Expected response:** 61 + ```json 62 + { 63 + "records": [ 64 + { 65 + "uri": "at://did:web:172.28.0.3%3A8080/io.atcr.hold.captain/self", 66 + "cid": "...", 67 + "value": { 68 + "$type": "io.atcr.hold.captain", 69 + "allowAllCrew": true, 70 + "public": false, 71 + "createdAt": "2025-10-22T..." 72 + } 73 + } 74 + ] 75 + } 76 + ``` 77 + 78 + ### 5. List Crew Records 79 + ```bash 80 + curl -s "http://172.28.0.3:8080/xrpc/com.atproto.repo.listRecords?repo=did:web:172.28.0.3%3A8080&collection=io.atcr.hold.crew" | jq . 81 + ``` 82 + 83 + **Expected response:** 84 + ```json 85 + { 86 + "records": [ 87 + { 88 + "uri": "at://did:web:172.28.0.3%3A8080/io.atcr.hold.crew/{rkey}", 89 + "cid": "...", 90 + "value": { 91 + "$type": "io.atcr.hold.crew", 92 + "did": "did:plc:...", 93 + "permissions": ["blob:read", "blob:write"], 94 + "createdAt": "2025-10-22T..." 95 + } 96 + } 97 + ] 98 + } 99 + ``` 100 + 101 + ### 6. Get Specific Record 102 + ```bash 103 + curl -s "http://172.28.0.3:8080/xrpc/com.atproto.repo.getRecord?repo=did:web:172.28.0.3%3A8080&collection=io.atcr.hold.captain&rkey=self" | jq . 104 + ``` 105 + 106 + ### 7. Get Blob 107 + ```bash 108 + # Replace with actual CID from your hold 109 + curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.getBlob?did=did:web:172.28.0.3%3A8080&cid=bafyreiabc123..." | jq . 110 + ``` 111 + 112 + **Expected response (for OCI blobs):** 113 + ```json 114 + { 115 + "url": "https://s3.amazonaws.com/bucket/path?presigned-params...", 116 + "expiresAt": "2025-10-22T12:15:00Z" 117 + } 118 + ``` 119 + 120 + ### 8. Subscribe to Repository Events (WebSocket) 121 + 122 + Using **websocat** (recommended): 123 + ```bash 124 + # Install: cargo install websocat 125 + websocat "ws://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos" 126 + ``` 127 + 128 + Using **wscat**: 129 + ```bash 130 + # Install: npm install -g wscat 131 + wscat -c "ws://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos" 132 + ``` 133 + 134 + Using **curl** (HTTP upgrade - may not work with all servers): 135 + ```bash 136 + curl -i -N \ 137 + -H "Connection: Upgrade" \ 138 + -H "Upgrade: websocket" \ 139 + -H "Sec-WebSocket-Version: 13" \ 140 + -H "Sec-WebSocket-Key: $(echo -n "test" | base64)" \ 141 + "http://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos" 142 + ``` 143 + 144 + **Expected response:** Stream of CBOR-encoded events (commits, identities, handles, etc.) 145 + 146 + ## DID Resolution 147 + 148 + ### Get DID Document 149 + ```bash 150 + curl -s "http://172.28.0.3:8080/.well-known/did.json" | jq . 151 + ``` 152 + 153 + **Expected response:** 154 + ```json 155 + { 156 + "@context": ["https://www.w3.org/ns/did/v1"], 157 + "id": "did:web:172.28.0.3%3A8080", 158 + "service": [ 159 + { 160 + "id": "#atproto_pds", 161 + "type": "AtprotoPersonalDataServer", 162 + "serviceEndpoint": "http://172.28.0.3:8080" 163 + } 164 + ] 165 + } 166 + ``` 167 + 168 + ### Get DID from Handle 169 + ```bash 170 + curl -s "http://172.28.0.3:8080/.well-known/atproto-did" 171 + ``` 172 + 173 + **Expected response:** Plain text DID 174 + ``` 175 + did:web:172.28.0.3%3A8080 176 + ``` 177 + 178 + ## Running the Test Script 179 + 180 + ```bash 181 + # Default (uses 172.28.0.3:8080) 182 + ./test-hold-endpoints.sh 183 + 184 + # Custom hold URL 185 + ./test-hold-endpoints.sh "http://localhost:8080" 186 + 187 + # Custom hold URL and DID 188 + ./test-hold-endpoints.sh "http://localhost:8080" "did:web:localhost%3A8080" 189 + ``` 190 + 191 + ## Troubleshooting 192 + 193 + ### "Connection refused" 194 + - Ensure hold service is running: `docker ps` or check process 195 + - Verify IP address: `docker inspect <container> | grep IPAddress` 196 + 197 + ### "Empty response" or "404 Not Found" 198 + - Check hold service logs for errors 199 + - Verify DID format (use URL-encoded version with `%3A` for `:`) 200 + - Ensure hold has been initialized (should have captain record) 201 + 202 + ### WebSocket connection fails 203 + - Install websocat: `cargo install websocat` 204 + - Or install wscat: `npm install -g wscat` 205 + - WebSocket endpoints only work with proper WS clients, not regular curl 206 + 207 + ### "No records found" 208 + - Captain record created on hold startup if `HOLD_OWNER` is set 209 + - Crew records created when users call `io.atcr.hold.requestCrew` 210 + - Blobs only exist after pushing container images 211 + 212 + ## Next Steps 213 + 214 + After verifying these endpoints work: 215 + 1. Test OCI upload endpoints (requires authentication) 216 + 2. Push a real container image to create blob data 217 + 3. Test blob retrieval with real CIDs 218 + 4. Monitor WebSocket events during pushes
+78
pkg/appview/db/annotations.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "time" 6 + ) 7 + 8 + // GetRepositoryAnnotations retrieves all annotations for a repository 9 + func GetRepositoryAnnotations(db *sql.DB, did, repository string) (map[string]string, error) { 10 + rows, err := db.Query(` 11 + SELECT key, value 12 + FROM repository_annotations 13 + WHERE did = ? AND repository = ? 14 + `, did, repository) 15 + if err != nil { 16 + return nil, err 17 + } 18 + defer rows.Close() 19 + 20 + annotations := make(map[string]string) 21 + for rows.Next() { 22 + var key, value string 23 + if err := rows.Scan(&key, &value); err != nil { 24 + return nil, err 25 + } 26 + annotations[key] = value 27 + } 28 + 29 + return annotations, rows.Err() 30 + } 31 + 32 + // UpsertRepositoryAnnotations replaces all annotations for a repository 33 + // Only called when manifest has at least one non-empty annotation 34 + func UpsertRepositoryAnnotations(db *sql.DB, did, repository string, annotations map[string]string) error { 35 + tx, err := db.Begin() 36 + if err != nil { 37 + return err 38 + } 39 + defer tx.Rollback() 40 + 41 + // Delete existing annotations 42 + _, err = tx.Exec(` 43 + DELETE FROM repository_annotations 44 + WHERE did = ? AND repository = ? 45 + `, did, repository) 46 + if err != nil { 47 + return err 48 + } 49 + 50 + // Insert new annotations 51 + stmt, err := tx.Prepare(` 52 + INSERT INTO repository_annotations (did, repository, key, value, updated_at) 53 + VALUES (?, ?, ?, ?, ?) 54 + `) 55 + if err != nil { 56 + return err 57 + } 58 + defer stmt.Close() 59 + 60 + now := time.Now() 61 + for key, value := range annotations { 62 + _, err = stmt.Exec(did, repository, key, value, now) 63 + if err != nil { 64 + return err 65 + } 66 + } 67 + 68 + return tx.Commit() 69 + } 70 + 71 + // DeleteRepositoryAnnotations removes all annotations for a repository 72 + func DeleteRepositoryAnnotations(db *sql.DB, did, repository string) error { 73 + _, err := db.Exec(` 74 + DELETE FROM repository_annotations 75 + WHERE did = ? AND repository = ? 76 + `, did, repository) 77 + return err 78 + }
+6 -45
pkg/appview/db/migrations/0003_add_readme_url_to_manifests.yaml
··· 1 - description: Add readme_url column to manifests table (idempotent - handles both fresh and existing databases) 1 + description: Add readme_url to manifests (obsolete - kept for migration history) 2 2 query: | 3 - -- Idempotent migration: adds readme_url column if it doesn't exist 4 - -- Works for both fresh installs (where schema.sql created it) and existing databases 5 - 6 - -- Create temp table with new schema 7 - CREATE TABLE manifests_temp ( 8 - id INTEGER PRIMARY KEY AUTOINCREMENT, 9 - did TEXT NOT NULL, 10 - repository TEXT NOT NULL, 11 - digest TEXT NOT NULL, 12 - hold_endpoint TEXT NOT NULL, 13 - schema_version INTEGER NOT NULL, 14 - media_type TEXT NOT NULL, 15 - config_digest TEXT, 16 - config_size INTEGER, 17 - created_at TIMESTAMP NOT NULL, 18 - title TEXT, 19 - description TEXT, 20 - source_url TEXT, 21 - documentation_url TEXT, 22 - licenses TEXT, 23 - icon_url TEXT, 24 - readme_url TEXT, 25 - UNIQUE(did, repository, digest), 26 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 27 - ); 28 - 29 - -- Copy data from existing manifests table 30 - -- Use INSERT OR IGNORE to handle case where table is already correct 31 - INSERT OR IGNORE INTO manifests_temp 32 - SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, 33 - config_digest, config_size, created_at, title, description, source_url, 34 - documentation_url, licenses, icon_url, 35 - NULL as readme_url -- Will be NULL for existing data 36 - FROM manifests; 37 - 38 - -- Only proceed with table swap if we actually copied data 39 - -- (manifests_temp will be empty if manifests table already has readme_url) 40 - DROP TABLE IF EXISTS manifests; 41 - ALTER TABLE manifests_temp RENAME TO manifests; 42 - 43 - -- Recreate indexes 44 - CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 45 - CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 46 - CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); 3 + -- This migration is obsolete. The readme_url and other annotations 4 + -- are now stored in the repository_annotations table (see schema.sql). 5 + -- Backfill will populate annotation data from PDS records. 6 + -- This migration is kept as a no-op to maintain migration history. 7 + SELECT 1;
+35
pkg/appview/db/migrations/0004_remove_annotation_columns_from_manifests.yaml
··· 1 + description: Remove annotation columns from manifests table 2 + query: | 3 + -- Drop annotation columns from manifests table (if they exist) 4 + -- Annotations are now stored in repository_annotations table 5 + -- SQLite doesn't support DROP COLUMN IF EXISTS, so we recreate the table 6 + 7 + -- Create new manifests table without annotation columns 8 + CREATE TABLE IF NOT EXISTS manifests_new ( 9 + id INTEGER PRIMARY KEY AUTOINCREMENT, 10 + did TEXT NOT NULL, 11 + repository TEXT NOT NULL, 12 + digest TEXT NOT NULL, 13 + hold_endpoint TEXT NOT NULL, 14 + schema_version INTEGER NOT NULL, 15 + media_type TEXT NOT NULL, 16 + config_digest TEXT, 17 + config_size INTEGER, 18 + created_at TIMESTAMP NOT NULL, 19 + UNIQUE(did, repository, digest), 20 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 21 + ); 22 + 23 + -- Copy data (only core fields, annotation columns are dropped) 24 + INSERT INTO manifests_new (id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at) 25 + SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at 26 + FROM manifests; 27 + 28 + -- Swap tables 29 + DROP TABLE manifests; 30 + ALTER TABLE manifests_new RENAME TO manifests; 31 + 32 + -- Recreate indexes 33 + CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 34 + CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 35 + CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
+12 -21
pkg/appview/db/models.go
··· 13 13 14 14 // Manifest represents an OCI manifest stored in the cache 15 15 type Manifest struct { 16 - ID int64 17 - DID string 18 - Repository string 19 - Digest string 20 - HoldEndpoint string 21 - SchemaVersion int 22 - MediaType string 23 - ConfigDigest string 24 - ConfigSize int64 25 - CreatedAt time.Time 26 - Title string 27 - Description string 28 - SourceURL string 29 - DocumentationURL string 30 - Licenses string 31 - IconURL string 32 - ReadmeURL string 33 - PlatformOS string // UNUSED: Reserved for future use, always NULL 34 - PlatformArchitecture string // UNUSED: Reserved for future use, always NULL 35 - PlatformVariant string // UNUSED: Reserved for future use, always NULL 36 - PlatformOSVersion string // UNUSED: Reserved for future use, always NULL 16 + ID int64 17 + DID string 18 + Repository string 19 + Digest string 20 + HoldEndpoint string 21 + SchemaVersion int 22 + MediaType string 23 + ConfigDigest string 24 + ConfigSize int64 25 + CreatedAt time.Time 26 + // Annotations removed - now stored in repository_annotations table 37 27 } 38 28 39 29 // Layer represents a layer in a manifest ··· 100 90 Licenses string 101 91 IconURL string 102 92 ReadmeURL string 93 + Version string 103 94 } 104 95 105 96 // RepositoryStats represents statistics for a repository
+72 -258
pkg/appview/db/queries.go
··· 39 39 t.repository, 40 40 t.tag, 41 41 t.digest, 42 - COALESCE(m.title, ''), 43 - COALESCE(m.description, ''), 44 - COALESCE(m.icon_url, ''), 42 + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.title'), ''), 43 + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.description'), ''), 44 + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''), 45 45 COALESCE(rs.pull_count, 0), 46 46 COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), 47 47 t.created_at, ··· 94 94 return pushes, total, nil 95 95 } 96 96 97 - // SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and manifest annotations 97 + // SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and annotations 98 98 func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, error) { 99 99 // Escape LIKE wildcards so they're treated literally 100 100 query = escapeLikePattern(query) ··· 109 109 t.repository, 110 110 t.tag, 111 111 t.digest, 112 - COALESCE(m.title, ''), 113 - COALESCE(m.description, ''), 114 - COALESCE(m.icon_url, ''), 112 + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.title'), ''), 113 + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.description'), ''), 114 + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''), 115 115 COALESCE(rs.pull_count, 0), 116 116 COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), 117 117 t.created_at, ··· 123 123 WHERE u.handle LIKE ? ESCAPE '\' 124 124 OR u.did = ? 125 125 OR t.repository LIKE ? ESCAPE '\' 126 - OR m.title LIKE ? ESCAPE '\' 127 - OR m.description LIKE ? ESCAPE '\' 126 + OR EXISTS ( 127 + SELECT 1 FROM repository_annotations ra 128 + WHERE ra.did = u.did AND ra.repository = t.repository 129 + AND ra.value LIKE ? ESCAPE '\' 130 + ) 128 131 ORDER BY t.created_at DESC 129 132 LIMIT ? OFFSET ? 130 133 ` 131 134 132 - rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, searchPattern, limit, offset) 135 + rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, limit, offset) 133 136 if err != nil { 134 137 return nil, 0, err 135 138 } ··· 153 156 WHERE u.handle LIKE ? ESCAPE '\' 154 157 OR u.did = ? 155 158 OR t.repository LIKE ? ESCAPE '\' 156 - OR m.title LIKE ? ESCAPE '\' 157 - OR m.description LIKE ? ESCAPE '\' 159 + OR EXISTS ( 160 + SELECT 1 FROM repository_annotations ra 161 + WHERE ra.did = u.did AND ra.repository = t.repository 162 + AND ra.value LIKE ? ESCAPE '\' 163 + ) 158 164 ` 159 165 160 166 var total int 161 - if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern, searchPattern).Scan(&total); err != nil { 167 + if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil { 162 168 return nil, 0, err 163 169 } 164 170 ··· 242 248 // Get manifests for this repo 243 249 manifestRows, err := db.Query(` 244 250 SELECT id, digest, hold_endpoint, schema_version, media_type, 245 - config_digest, config_size, created_at, 246 - title, description, source_url, documentation_url, licenses, icon_url 251 + config_digest, config_size, created_at 247 252 FROM manifests 248 253 WHERE did = ? AND repository = ? 249 254 ORDER BY created_at DESC ··· 258 263 m.DID = did 259 264 m.Repository = r.Name 260 265 261 - // Use sql.NullString for nullable annotation fields 262 - var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString 263 - 264 266 if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 265 - &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt, 266 - &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil { 267 + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt); err != nil { 267 268 manifestRows.Close() 268 269 return nil, err 269 270 } 270 271 271 - // Convert NullString to string 272 - if title.Valid { 273 - m.Title = title.String 274 - } 275 - if description.Valid { 276 - m.Description = description.String 277 - } 278 - if sourceURL.Valid { 279 - m.SourceURL = sourceURL.String 280 - } 281 - if documentationURL.Valid { 282 - m.DocumentationURL = documentationURL.String 283 - } 284 - if licenses.Valid { 285 - m.Licenses = licenses.String 286 - } 287 - if iconURL.Valid { 288 - m.IconURL = iconURL.String 289 - } 290 - 291 272 r.Manifests = append(r.Manifests, m) 292 273 } 293 274 manifestRows.Close() 294 275 295 - // Aggregate repository-level annotations from most recent manifest 296 - if len(r.Manifests) > 0 { 297 - latest := r.Manifests[0] 298 - r.Title = latest.Title 299 - r.Description = latest.Description 300 - r.SourceURL = latest.SourceURL 301 - r.DocumentationURL = latest.DocumentationURL 302 - r.Licenses = latest.Licenses 303 - r.IconURL = latest.IconURL 276 + // Fetch repository-level annotations from annotations table 277 + annotations, err := GetRepositoryAnnotations(db, did, r.Name) 278 + if err != nil { 279 + return nil, err 304 280 } 305 281 282 + r.Title = annotations["org.opencontainers.image.title"] 283 + r.Description = annotations["org.opencontainers.image.description"] 284 + r.SourceURL = annotations["org.opencontainers.image.source"] 285 + r.DocumentationURL = annotations["org.opencontainers.image.documentation"] 286 + r.Licenses = annotations["org.opencontainers.image.licenses"] 287 + r.IconURL = annotations["io.atcr.icon"] 288 + r.ReadmeURL = annotations["io.atcr.readme"] 289 + 306 290 repos = append(repos, r) 307 291 } 308 292 309 293 return repos, nil 310 294 } 311 295 312 - // GetRepositoryMetadata retrieves metadata for a repository from its most recent manifest 313 - // Prioritizes manifests with non-empty metadata fields 314 - func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string, err error) { 315 - var titleNull, descriptionNull, sourceURLNull, documentationURLNull, licensesNull, iconURLNull, readmeURLNull sql.NullString 316 - 317 - // Try to find a manifest with metadata first (prefer manifests with any non-empty annotation field) 318 - err = db.QueryRow(` 319 - SELECT title, description, source_url, documentation_url, licenses, icon_url, readme_url 320 - FROM manifests 321 - WHERE did = ? AND repository = ? 322 - AND ( 323 - (title IS NOT NULL AND title != '') 324 - OR (description IS NOT NULL AND description != '') 325 - OR (source_url IS NOT NULL AND source_url != '') 326 - OR (documentation_url IS NOT NULL AND documentation_url != '') 327 - OR (licenses IS NOT NULL AND licenses != '') 328 - OR (icon_url IS NOT NULL AND icon_url != '') 329 - OR (readme_url IS NOT NULL AND readme_url != '') 330 - ) 331 - ORDER BY created_at DESC 332 - LIMIT 1 333 - `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull, &readmeURLNull) 334 - 335 - // If no manifest with metadata found, fall back to latest manifest (any type) 336 - if err == sql.ErrNoRows { 337 - err = db.QueryRow(` 338 - SELECT title, description, source_url, documentation_url, licenses, icon_url, readme_url 339 - FROM manifests 340 - WHERE did = ? AND repository = ? 341 - ORDER BY created_at DESC 342 - LIMIT 1 343 - `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull, &readmeURLNull) 344 - } 345 - 346 - if err == sql.ErrNoRows { 347 - // No manifests found - return empty strings 348 - return "", "", "", "", "", "", "", nil 349 - } 350 - if err != nil { 351 - return "", "", "", "", "", "", "", err 352 - } 353 - 354 - // Convert NullString to string 355 - if titleNull.Valid { 356 - title = titleNull.String 357 - } 358 - if descriptionNull.Valid { 359 - description = descriptionNull.String 360 - } 361 - if sourceURLNull.Valid { 362 - sourceURL = sourceURLNull.String 363 - } 364 - if documentationURLNull.Valid { 365 - documentationURL = documentationURLNull.String 366 - } 367 - if licensesNull.Valid { 368 - licenses = licensesNull.String 369 - } 370 - if iconURLNull.Valid { 371 - iconURL = iconURLNull.String 372 - } 373 - if readmeURLNull.Valid { 374 - readmeURL = readmeURLNull.String 375 - } 376 - 377 - return title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, nil 296 + // GetRepositoryMetadata retrieves metadata for a repository from annotations table 297 + // Returns a map of annotation key -> value for easy access in templates and handlers 298 + func GetRepositoryMetadata(db *sql.DB, did string, repository string) (map[string]string, error) { 299 + return GetRepositoryAnnotations(db, did, repository) 378 300 } 379 301 380 302 // GetUserByDID retrieves a user by DID ··· 555 477 } 556 478 557 479 // InsertManifest inserts or updates a manifest record 558 - // Uses UPSERT to update labels/annotations if manifest already exists 480 + // Uses UPSERT to update core metadata if manifest already exists 559 481 // Returns the manifest ID (works correctly for both insert and update) 482 + // Note: Annotations are stored separately in repository_annotations table 560 483 func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) { 561 484 _, err := db.Exec(` 562 485 INSERT INTO manifests 563 486 (did, repository, digest, hold_endpoint, schema_version, media_type, 564 - config_digest, config_size, created_at, 565 - title, description, source_url, documentation_url, licenses, icon_url, readme_url) 566 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 487 + config_digest, config_size, created_at) 488 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 567 489 ON CONFLICT(did, repository, digest) DO UPDATE SET 568 490 hold_endpoint = excluded.hold_endpoint, 569 491 schema_version = excluded.schema_version, 570 492 media_type = excluded.media_type, 571 493 config_digest = excluded.config_digest, 572 - config_size = excluded.config_size, 573 - title = excluded.title, 574 - description = excluded.description, 575 - source_url = excluded.source_url, 576 - documentation_url = excluded.documentation_url, 577 - licenses = excluded.licenses, 578 - icon_url = excluded.icon_url, 579 - readme_url = excluded.readme_url 494 + config_size = excluded.config_size 580 495 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, 581 496 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, 582 - manifest.ConfigSize, manifest.CreatedAt, 583 - manifest.Title, manifest.Description, manifest.SourceURL, 584 - manifest.DocumentationURL, manifest.Licenses, manifest.IconURL, manifest.ReadmeURL) 497 + manifest.ConfigSize, manifest.CreatedAt) 585 498 586 499 if err != nil { 587 500 return 0, err ··· 719 632 } 720 633 721 634 // GetManifest fetches a single manifest by digest 635 + // Note: Annotations are stored separately in repository_annotations table 722 636 func GetManifest(db *sql.DB, digest string) (*Manifest, error) { 723 637 var m Manifest 724 - 725 - // Use sql.NullString for nullable annotation fields 726 - var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL sql.NullString 727 638 728 639 err := db.QueryRow(` 729 640 SELECT id, did, repository, digest, hold_endpoint, schema_version, 730 - media_type, config_digest, config_size, created_at, 731 - title, description, source_url, documentation_url, licenses, icon_url, readme_url 641 + media_type, config_digest, config_size, created_at 732 642 FROM manifests 733 643 WHERE digest = ? 734 644 `, digest).Scan(&m.ID, &m.DID, &m.Repository, &m.Digest, &m.HoldEndpoint, 735 645 &m.SchemaVersion, &m.MediaType, &m.ConfigDigest, &m.ConfigSize, 736 - &m.CreatedAt, 737 - &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL, &readmeURL) 646 + &m.CreatedAt) 738 647 739 648 if err != nil { 740 649 return nil, err 741 650 } 742 651 743 - // Convert NullString to string 744 - if title.Valid { 745 - m.Title = title.String 746 - } 747 - if description.Valid { 748 - m.Description = description.String 749 - } 750 - if sourceURL.Valid { 751 - m.SourceURL = sourceURL.String 752 - } 753 - if documentationURL.Valid { 754 - m.DocumentationURL = documentationURL.String 755 - } 756 - if licenses.Valid { 757 - m.Licenses = licenses.String 758 - } 759 - if iconURL.Valid { 760 - m.IconURL = iconURL.String 761 - } 762 - if readmeURL.Valid { 763 - m.ReadmeURL = readmeURL.String 764 - } 765 - 766 652 return &m, nil 767 653 } 768 654 ··· 855 741 856 742 // GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests 857 743 // Filters out platform-specific manifests that are referenced by manifest lists 744 + // Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them 858 745 func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int) ([]ManifestWithMetadata, error) { 859 746 rows, err := db.Query(` 860 747 WITH manifest_list_children AS ( ··· 866 753 ) 867 754 SELECT 868 755 m.id, m.did, m.repository, m.digest, m.media_type, 869 - m.schema_version, m.created_at, m.title, m.description, 870 - m.source_url, m.documentation_url, m.licenses, m.icon_url, 756 + m.schema_version, m.created_at, 871 757 m.config_digest, m.config_size, m.hold_endpoint, 872 758 GROUP_CONCAT(DISTINCT t.tag) as tags, 873 759 COUNT(DISTINCT mr.digest) as platform_count ··· 895 781 var manifests []ManifestWithMetadata 896 782 for rows.Next() { 897 783 var m ManifestWithMetadata 898 - var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString 784 + var tags, configDigest sql.NullString 899 785 var configSize sql.NullInt64 900 786 901 787 if err := rows.Scan( 902 788 &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType, 903 - &m.SchemaVersion, &m.CreatedAt, &title, &description, 904 - &sourceURL, &documentationURL, &licenses, &iconURL, 789 + &m.SchemaVersion, &m.CreatedAt, 905 790 &configDigest, &configSize, &m.HoldEndpoint, 906 791 &tags, &m.PlatformCount, 907 792 ); err != nil { ··· 909 794 } 910 795 911 796 // Set nullable fields 912 - if title.Valid { 913 - m.Title = title.String 914 - } 915 - if description.Valid { 916 - m.Description = description.String 917 - } 918 - if sourceURL.Valid { 919 - m.SourceURL = sourceURL.String 920 - } 921 - if documentationURL.Valid { 922 - m.DocumentationURL = documentationURL.String 923 - } 924 - if licenses.Valid { 925 - m.Licenses = licenses.String 926 - } 927 - if iconURL.Valid { 928 - m.IconURL = iconURL.String 929 - } 930 797 if configDigest.Valid { 931 798 m.ConfigDigest = configDigest.String 932 799 } ··· 998 865 } 999 866 1000 867 // GetManifestDetail returns a manifest with full platform details and tags 868 + // Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them 1001 869 func GetManifestDetail(db *sql.DB, did, repository, digest string) (*ManifestWithMetadata, error) { 1002 870 // First, get the manifest and its tags 1003 871 var m ManifestWithMetadata 1004 - var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString 872 + var tags, configDigest sql.NullString 1005 873 var configSize sql.NullInt64 1006 874 1007 875 err := db.QueryRow(` 1008 876 SELECT 1009 877 m.id, m.did, m.repository, m.digest, m.media_type, 1010 - m.schema_version, m.created_at, m.title, m.description, 1011 - m.source_url, m.documentation_url, m.licenses, m.icon_url, 878 + m.schema_version, m.created_at, 1012 879 m.config_digest, m.config_size, m.hold_endpoint, 1013 880 GROUP_CONCAT(DISTINCT t.tag) as tags 1014 881 FROM manifests m ··· 1017 884 GROUP BY m.id 1018 885 `, did, repository, digest).Scan( 1019 886 &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType, 1020 - &m.SchemaVersion, &m.CreatedAt, &title, &description, 1021 - &sourceURL, &documentationURL, &licenses, &iconURL, 887 + &m.SchemaVersion, &m.CreatedAt, 1022 888 &configDigest, &configSize, &m.HoldEndpoint, 1023 889 &tags, 1024 890 ) ··· 1031 897 } 1032 898 1033 899 // Set nullable fields 1034 - if title.Valid { 1035 - m.Title = title.String 1036 - } 1037 - if description.Valid { 1038 - m.Description = description.String 1039 - } 1040 - if sourceURL.Valid { 1041 - m.SourceURL = sourceURL.String 1042 - } 1043 - if documentationURL.Valid { 1044 - m.DocumentationURL = documentationURL.String 1045 - } 1046 - if licenses.Valid { 1047 - m.Licenses = licenses.String 1048 - } 1049 - if iconURL.Valid { 1050 - m.IconURL = iconURL.String 1051 - } 1052 900 if configDigest.Valid { 1053 901 m.ConfigDigest = configDigest.String 1054 902 } ··· 1303 1151 // Get manifests for this repo 1304 1152 manifestRows, err := db.Query(` 1305 1153 SELECT id, digest, hold_endpoint, schema_version, media_type, 1306 - config_digest, config_size, created_at, 1307 - title, description, source_url, documentation_url, licenses, icon_url 1154 + config_digest, config_size, created_at 1308 1155 FROM manifests 1309 1156 WHERE did = ? AND repository = ? 1310 1157 ORDER BY created_at DESC ··· 1319 1166 m.DID = did 1320 1167 m.Repository = repository 1321 1168 1322 - // Use sql.NullString for nullable annotation fields 1323 - var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString 1324 - 1325 1169 if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 1326 - &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt, 1327 - &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil { 1170 + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt); err != nil { 1328 1171 manifestRows.Close() 1329 1172 return nil, err 1330 - } 1331 - 1332 - // Convert NullString to string 1333 - if title.Valid { 1334 - m.Title = title.String 1335 - } 1336 - if description.Valid { 1337 - m.Description = description.String 1338 - } 1339 - if sourceURL.Valid { 1340 - m.SourceURL = sourceURL.String 1341 - } 1342 - if documentationURL.Valid { 1343 - m.DocumentationURL = documentationURL.String 1344 - } 1345 - if licenses.Valid { 1346 - m.Licenses = licenses.String 1347 - } 1348 - if iconURL.Valid { 1349 - m.IconURL = iconURL.String 1350 1173 } 1351 1174 1352 1175 r.Manifests = append(r.Manifests, m) 1353 1176 } 1354 1177 manifestRows.Close() 1355 1178 1356 - // Aggregate repository-level annotations from most recent manifest 1357 - if len(r.Manifests) > 0 { 1358 - latest := r.Manifests[0] 1359 - r.Title = latest.Title 1360 - r.Description = latest.Description 1361 - r.SourceURL = latest.SourceURL 1362 - r.DocumentationURL = latest.DocumentationURL 1363 - r.Licenses = latest.Licenses 1364 - r.IconURL = latest.IconURL 1179 + // Fetch repository-level annotations from annotations table 1180 + annotations, err := GetRepositoryAnnotations(db, did, repository) 1181 + if err != nil { 1182 + return nil, err 1365 1183 } 1184 + 1185 + r.Title = annotations["org.opencontainers.image.title"] 1186 + r.Description = annotations["org.opencontainers.image.description"] 1187 + r.SourceURL = annotations["org.opencontainers.image.source"] 1188 + r.DocumentationURL = annotations["org.opencontainers.image.documentation"] 1189 + r.Licenses = annotations["org.opencontainers.image.licenses"] 1190 + r.IconURL = annotations["io.atcr.icon"] 1191 + r.ReadmeURL = annotations["io.atcr.readme"] 1366 1192 1367 1193 return &r, nil 1368 1194 } ··· 1648 1474 m.did, 1649 1475 u.handle, 1650 1476 m.repository, 1651 - m.title, 1652 - m.description, 1653 - m.icon_url, 1477 + COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''), 1478 + COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''), 1479 + COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''), 1654 1480 rs.pull_count, 1655 1481 rs.star_count 1656 1482 FROM latest_manifests lm ··· 1670 1496 var featured []FeaturedRepository 1671 1497 for rows.Next() { 1672 1498 var f FeaturedRepository 1673 - var title, description, iconURL sql.NullString 1674 1499 1675 1500 if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository, 1676 - &title, &description, &iconURL, &f.PullCount, &f.StarCount); err != nil { 1501 + &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount); err != nil { 1677 1502 return nil, err 1678 - } 1679 - 1680 - // Convert NullString to string 1681 - if title.Valid { 1682 - f.Title = title.String 1683 - } 1684 - if description.Valid { 1685 - f.Description = description.String 1686 - } 1687 - if iconURL.Valid { 1688 - f.IconURL = iconURL.String 1689 1503 } 1690 1504 1691 1505 featured = append(featured, f)
+156 -90
pkg/appview/db/queries_test.go
··· 25 25 t.Fatalf("Failed to insert user: %v", err) 26 26 } 27 27 28 - // Test 1: No manifests - should return empty strings 29 - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent") 28 + // Test 1: No manifests - should return empty map 29 + metadata, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent") 30 30 if err != nil { 31 31 t.Fatalf("Expected no error for nonexistent repo, got: %v", err) 32 32 } 33 - if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" || readmeURL != "" { 34 - t.Error("Expected all empty strings for nonexistent repository") 33 + if len(metadata) != 0 { 34 + t.Errorf("Expected empty map for nonexistent repository, got %d entries", len(metadata)) 35 35 } 36 36 37 - // Test 2: Insert manifest with metadata 37 + // Test 2: Insert manifest and annotations 38 38 _, err = db.Exec(` 39 - INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at, 40 - title, description, source_url, documentation_url, licenses, icon_url) 41 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 39 + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at) 40 + VALUES (?, ?, ?, ?, ?, ?, ?) 42 41 `, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", 43 - time.Now().Add(-2*time.Hour), 44 - "My App", "A cool application", "https://github.com/user/myapp", "https://docs.example.com", "MIT", "https://example.com/icon.png") 42 + time.Now().Add(-2*time.Hour)) 45 43 if err != nil { 46 44 t.Fatalf("Failed to insert manifest: %v", err) 45 + } 46 + 47 + // Insert annotations separately 48 + err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", map[string]string{ 49 + "org.opencontainers.image.title": "My App", 50 + "org.opencontainers.image.description": "A cool application", 51 + "org.opencontainers.image.source": "https://github.com/user/myapp", 52 + "org.opencontainers.image.documentation": "https://docs.example.com", 53 + "org.opencontainers.image.licenses": "MIT", 54 + "io.atcr.icon": "https://example.com/icon.png", 55 + }) 56 + if err != nil { 57 + t.Fatalf("Failed to insert annotations: %v", err) 47 58 } 48 59 49 60 // Test 3: Retrieve metadata 50 - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") 61 + metadata, err = GetRepositoryMetadata(db, testUser.DID, "myapp") 51 62 if err != nil { 52 63 t.Fatalf("Failed to get repository metadata: %v", err) 53 64 } 54 65 55 - if title != "My App" { 56 - t.Errorf("Expected title 'My App', got '%s'", title) 66 + if metadata["org.opencontainers.image.title"] != "My App" { 67 + t.Errorf("Expected title 'My App', got '%s'", metadata["org.opencontainers.image.title"]) 57 68 } 58 - if description != "A cool application" { 59 - t.Errorf("Expected description 'A cool application', got '%s'", description) 69 + if metadata["org.opencontainers.image.description"] != "A cool application" { 70 + t.Errorf("Expected description 'A cool application', got '%s'", metadata["org.opencontainers.image.description"]) 60 71 } 61 - if sourceURL != "https://github.com/user/myapp" { 62 - t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", sourceURL) 72 + if metadata["org.opencontainers.image.source"] != "https://github.com/user/myapp" { 73 + t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", metadata["org.opencontainers.image.source"]) 63 74 } 64 - if documentationURL != "https://docs.example.com" { 65 - t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", documentationURL) 75 + if metadata["org.opencontainers.image.documentation"] != "https://docs.example.com" { 76 + t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", metadata["org.opencontainers.image.documentation"]) 66 77 } 67 - if licenses != "MIT" { 68 - t.Errorf("Expected licenses 'MIT', got '%s'", licenses) 78 + if metadata["org.opencontainers.image.licenses"] != "MIT" { 79 + t.Errorf("Expected licenses 'MIT', got '%s'", metadata["org.opencontainers.image.licenses"]) 69 80 } 70 - if iconURL != "https://example.com/icon.png" { 71 - t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", iconURL) 81 + if metadata["io.atcr.icon"] != "https://example.com/icon.png" { 82 + t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", metadata["io.atcr.icon"]) 72 83 } 73 84 74 - // Test 4: Insert newer manifest with different metadata 85 + // Test 4: Insert newer manifest with different annotations 75 86 _, err = db.Exec(` 76 - INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at, 77 - title, description, source_url, documentation_url, licenses, icon_url) 78 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 87 + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at) 88 + VALUES (?, ?, ?, ?, ?, ?, ?) 79 89 `, testUser.DID, "myapp", "sha256:def456", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", 80 - time.Now(), // Most recent 81 - "My App v2", "An even cooler application", "https://github.com/user/myapp-v2", "https://v2.docs.example.com", "Apache-2.0", "https://example.com/icon-v2.png") 90 + time.Now()) // Most recent 82 91 if err != nil { 83 92 t.Fatalf("Failed to insert newer manifest: %v", err) 84 93 } 85 94 95 + // Update annotations with new values (simulates latest manifest having different annotations) 96 + err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", map[string]string{ 97 + "org.opencontainers.image.title": "My App v2", 98 + "org.opencontainers.image.description": "An even cooler application", 99 + "org.opencontainers.image.source": "https://github.com/user/myapp-v2", 100 + "org.opencontainers.image.documentation": "https://v2.docs.example.com", 101 + "org.opencontainers.image.licenses": "Apache-2.0", 102 + "io.atcr.icon": "https://example.com/icon-v2.png", 103 + }) 104 + if err != nil { 105 + t.Fatalf("Failed to update annotations: %v", err) 106 + } 107 + 86 108 // Test 5: Should return metadata from most recent manifest 87 - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") 109 + metadata, err = GetRepositoryMetadata(db, testUser.DID, "myapp") 88 110 if err != nil { 89 111 t.Fatalf("Failed to get repository metadata: %v", err) 90 112 } 91 113 92 - if title != "My App v2" { 93 - t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", title) 114 + if metadata["org.opencontainers.image.title"] != "My App v2" { 115 + t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", metadata["org.opencontainers.image.title"]) 94 116 } 95 - if description != "An even cooler application" { 96 - t.Errorf("Expected description from newest manifest, got '%s'", description) 117 + if metadata["org.opencontainers.image.description"] != "An even cooler application" { 118 + t.Errorf("Expected description from newest manifest, got '%s'", metadata["org.opencontainers.image.description"]) 97 119 } 98 - if licenses != "Apache-2.0" { 99 - t.Errorf("Expected licenses 'Apache-2.0', got '%s'", licenses) 120 + if metadata["org.opencontainers.image.licenses"] != "Apache-2.0" { 121 + t.Errorf("Expected licenses 'Apache-2.0', got '%s'", metadata["org.opencontainers.image.licenses"]) 100 122 } 101 123 102 124 // Test 6: Manifest with NULL metadata fields ··· 108 130 t.Fatalf("Failed to insert minimal manifest: %v", err) 109 131 } 110 132 111 - // Test 7: Should handle NULL fields gracefully 112 - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app") 133 + // Test 7: Should handle missing annotations gracefully 134 + metadata, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app") 113 135 if err != nil { 114 136 t.Fatalf("Failed to get repository metadata for minimal app: %v", err) 115 137 } 116 138 117 - if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" || readmeURL != "" { 118 - t.Error("Expected all empty strings for manifest with NULL metadata fields") 139 + if len(metadata) != 0 { 140 + t.Error("Expected empty map for manifest with no annotations") 119 141 } 120 142 } 121 143 ··· 139 161 t.Fatalf("Failed to insert user: %v", err) 140 162 } 141 163 142 - // Test 1: Insert new manifest with all fields populated 164 + // Test 1: Insert new manifest with core fields 143 165 manifest1 := &Manifest{ 144 - DID: testUser.DID, 145 - Repository: "myapp", 146 - Digest: "sha256:abc123", 147 - HoldEndpoint: "did:web:hold.example.com", 148 - SchemaVersion: 2, 149 - MediaType: "application/vnd.oci.image.manifest.v1+json", 150 - ConfigDigest: "sha256:config123", 151 - ConfigSize: 1024, 152 - CreatedAt: time.Now(), 153 - Title: "My App", 154 - Description: "A cool application", 155 - SourceURL: "https://github.com/user/myapp", 156 - DocumentationURL: "https://docs.example.com", 157 - Licenses: "MIT", 158 - IconURL: "https://example.com/icon.png", 159 - ReadmeURL: "https://github.com/user/myapp/blob/main/README.md", 166 + DID: testUser.DID, 167 + Repository: "myapp", 168 + Digest: "sha256:abc123", 169 + HoldEndpoint: "did:web:hold.example.com", 170 + SchemaVersion: 2, 171 + MediaType: "application/vnd.oci.image.manifest.v1+json", 172 + ConfigDigest: "sha256:config123", 173 + ConfigSize: 1024, 174 + CreatedAt: time.Now(), 160 175 } 161 176 162 177 id1, err := InsertManifest(db, manifest1) ··· 167 182 t.Error("Expected non-zero manifest ID") 168 183 } 169 184 185 + // Insert annotations separately 186 + annotations := map[string]string{ 187 + "org.opencontainers.image.title": "My App", 188 + "org.opencontainers.image.description": "A cool application", 189 + "org.opencontainers.image.source": "https://github.com/user/myapp", 190 + "org.opencontainers.image.documentation": "https://docs.example.com", 191 + "org.opencontainers.image.licenses": "MIT", 192 + "io.atcr.icon": "https://example.com/icon.png", 193 + "io.atcr.readme": "https://github.com/user/myapp/blob/main/README.md", 194 + } 195 + err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", annotations) 196 + if err != nil { 197 + t.Fatalf("Failed to insert annotations: %v", err) 198 + } 199 + 170 200 // Verify the manifest was inserted correctly 171 201 retrieved, err := GetManifest(db, manifest1.Digest) 172 202 if err != nil { ··· 175 205 if retrieved.ID != id1 { 176 206 t.Errorf("Expected ID %d, got %d", id1, retrieved.ID) 177 207 } 178 - if retrieved.Title != "My App" { 179 - t.Errorf("Expected title 'My App', got '%s'", retrieved.Title) 208 + 209 + // Verify annotations were inserted 210 + retrievedAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "myapp") 211 + if err != nil { 212 + t.Fatalf("Failed to retrieve annotations: %v", err) 180 213 } 181 - if retrieved.ReadmeURL != "https://github.com/user/myapp/blob/main/README.md" { 182 - t.Errorf("Expected readme_url, got '%s'", retrieved.ReadmeURL) 214 + if retrievedAnnotations["org.opencontainers.image.title"] != "My App" { 215 + t.Errorf("Expected title 'My App', got '%s'", retrievedAnnotations["org.opencontainers.image.title"]) 216 + } 217 + if retrievedAnnotations["io.atcr.readme"] != "https://github.com/user/myapp/blob/main/README.md" { 218 + t.Errorf("Expected readme_url, got '%s'", retrievedAnnotations["io.atcr.readme"]) 183 219 } 184 220 185 221 // Test 2: Insert manifest with minimal fields (NULLs for annotations) ··· 201 237 t.Error("Expected non-zero manifest ID for minimal manifest") 202 238 } 203 239 204 - retrieved2, err := GetManifest(db, manifest2.Digest) 240 + _, err = GetManifest(db, manifest2.Digest) 205 241 if err != nil { 206 242 t.Fatalf("Failed to retrieve minimal manifest: %v", err) 207 243 } 208 - if retrieved2.Title != "" { 209 - t.Errorf("Expected empty title for minimal manifest, got '%s'", retrieved2.Title) 244 + // Verify no annotations exist for minimal manifest 245 + minimalAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "minimal") 246 + if err != nil { 247 + t.Fatalf("Failed to get minimal annotations: %v", err) 248 + } 249 + if len(minimalAnnotations) != 0 { 250 + t.Errorf("Expected no annotations for minimal manifest, got %d", len(minimalAnnotations)) 210 251 } 211 252 212 253 // Test 3: Upsert existing manifest (same DID+repo+digest) - verify UPDATE path 213 254 manifest1Updated := &Manifest{ 214 - DID: testUser.DID, 215 - Repository: "myapp", 216 - Digest: "sha256:abc123", // Same digest - should trigger UPDATE 217 - HoldEndpoint: "did:web:hold2.example.com", 218 - SchemaVersion: 2, 219 - MediaType: "application/vnd.oci.image.manifest.v1+json", 220 - ConfigDigest: "sha256:newconfig", 221 - ConfigSize: 2048, 222 - CreatedAt: time.Now(), 223 - Title: "My App v2", 224 - Description: "An updated application", 225 - SourceURL: "https://github.com/user/myapp-v2", 226 - DocumentationURL: "https://v2.docs.example.com", 227 - Licenses: "Apache-2.0", 228 - IconURL: "https://example.com/icon-v2.png", 229 - ReadmeURL: "https://github.com/user/myapp/blob/v2/README.md", 255 + DID: testUser.DID, 256 + Repository: "myapp", 257 + Digest: "sha256:abc123", // Same digest - should trigger UPDATE 258 + HoldEndpoint: "did:web:hold2.example.com", 259 + SchemaVersion: 2, 260 + MediaType: "application/vnd.oci.image.manifest.v1+json", 261 + ConfigDigest: "sha256:newconfig", 262 + ConfigSize: 2048, 263 + CreatedAt: time.Now(), 230 264 } 231 265 232 266 id3, err := InsertManifest(db, manifest1Updated) ··· 238 272 t.Errorf("Expected upsert to return same ID %d, got %d", id1, id3) 239 273 } 240 274 275 + // Update annotations separately 276 + updatedAnnotations := map[string]string{ 277 + "org.opencontainers.image.title": "My App v2", 278 + "org.opencontainers.image.description": "An updated application", 279 + "org.opencontainers.image.source": "https://github.com/user/myapp-v2", 280 + "org.opencontainers.image.documentation": "https://v2.docs.example.com", 281 + "org.opencontainers.image.licenses": "Apache-2.0", 282 + "io.atcr.icon": "https://example.com/icon-v2.png", 283 + "io.atcr.readme": "https://github.com/user/myapp/blob/v2/README.md", 284 + } 285 + err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", updatedAnnotations) 286 + if err != nil { 287 + t.Fatalf("Failed to update annotations: %v", err) 288 + } 289 + 241 290 // Verify the manifest was updated 242 291 retrievedUpdated, err := GetManifest(db, manifest1.Digest) 243 292 if err != nil { 244 293 t.Fatalf("Failed to retrieve updated manifest: %v", err) 245 294 } 246 - if retrievedUpdated.Title != "My App v2" { 247 - t.Errorf("Expected updated title 'My App v2', got '%s'", retrievedUpdated.Title) 248 - } 249 295 if retrievedUpdated.HoldEndpoint != "did:web:hold2.example.com" { 250 296 t.Errorf("Expected updated hold_endpoint, got '%s'", retrievedUpdated.HoldEndpoint) 251 297 } 252 - if retrievedUpdated.ReadmeURL != "https://github.com/user/myapp/blob/v2/README.md" { 253 - t.Errorf("Expected updated readme_url, got '%s'", retrievedUpdated.ReadmeURL) 298 + 299 + // Verify annotations were updated 300 + retrievedUpdatedAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "myapp") 301 + if err != nil { 302 + t.Fatalf("Failed to retrieve updated annotations: %v", err) 303 + } 304 + if retrievedUpdatedAnnotations["org.opencontainers.image.title"] != "My App v2" { 305 + t.Errorf("Expected updated title 'My App v2', got '%s'", retrievedUpdatedAnnotations["org.opencontainers.image.title"]) 306 + } 307 + if retrievedUpdatedAnnotations["io.atcr.readme"] != "https://github.com/user/myapp/blob/v2/README.md" { 308 + t.Errorf("Expected updated readme_url, got '%s'", retrievedUpdatedAnnotations["io.atcr.readme"]) 254 309 } 255 310 256 311 // Test 4: Verify count - should have 2 manifests (not 3, because one was upserted) ··· 404 459 SchemaVersion: 2, 405 460 MediaType: "application/vnd.oci.image.manifest.v1+json", 406 461 CreatedAt: time.Now(), 407 - Title: "App 1", 408 462 }, 409 463 { 410 464 DID: testUser.DID, ··· 414 468 SchemaVersion: 2, 415 469 MediaType: "application/vnd.oci.image.manifest.v1+json", 416 470 CreatedAt: time.Now(), 417 - Title: "App 1 v2", 418 471 }, 419 472 { 420 473 DID: testUser.DID, ··· 424 477 SchemaVersion: 2, 425 478 MediaType: "application/vnd.oci.image.manifest.v1+json", 426 479 CreatedAt: time.Now(), 427 - Title: "App 2", 428 480 }, 429 481 } 430 482 ··· 435 487 } 436 488 } 437 489 490 + // Insert annotations for test manifests 491 + err = UpsertRepositoryAnnotations(db, testUser.DID, "app1", map[string]string{ 492 + "org.opencontainers.image.title": "App 1", 493 + }) 494 + if err != nil { 495 + t.Fatalf("Failed to insert app1 annotations: %v", err) 496 + } 497 + err = UpsertRepositoryAnnotations(db, testUser.DID, "app2", map[string]string{ 498 + "org.opencontainers.image.title": "App 2", 499 + }) 500 + if err != nil { 501 + t.Fatalf("Failed to insert app2 annotations: %v", err) 502 + } 503 + 438 504 // Test 1: GetManifest - found 439 505 retrieved, err := GetManifest(db, "sha256:aaa") 440 506 if err != nil { 441 507 t.Fatalf("Failed to get manifest: %v", err) 442 508 } 443 - if retrieved.Title != "App 1" { 444 - t.Errorf("Expected title 'App 1', got '%s'", retrieved.Title) 509 + if retrieved.Digest != "sha256:aaa" { 510 + t.Errorf("Expected digest 'sha256:aaa', got '%s'", retrieved.Digest) 445 511 } 446 512 447 513 // Test 2: GetManifest - not found
+12 -7
pkg/appview/db/schema.sql
··· 28 28 config_digest TEXT, 29 29 config_size INTEGER, 30 30 created_at TIMESTAMP NOT NULL, 31 - title TEXT, 32 - description TEXT, 33 - source_url TEXT, 34 - documentation_url TEXT, 35 - licenses TEXT, 36 - icon_url TEXT, 37 - readme_url TEXT, 38 31 UNIQUE(did, repository, digest), 39 32 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 40 33 ); 41 34 CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 42 35 CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 43 36 CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); 37 + 38 + CREATE TABLE IF NOT EXISTS repository_annotations ( 39 + did TEXT NOT NULL, 40 + repository TEXT NOT NULL, 41 + key TEXT NOT NULL, 42 + value TEXT NOT NULL, 43 + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 44 + PRIMARY KEY(did, repository, key), 45 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 46 + ); 47 + CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository); 48 + CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key); 44 49 45 50 CREATE TABLE IF NOT EXISTS layers ( 46 51 manifest_id INTEGER NOT NULL,
+10 -9
pkg/appview/handlers/repository.go
··· 136 136 ManifestCount: len(manifests), 137 137 } 138 138 139 - // Fetch repository metadata from most recent manifest 140 - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository) 139 + // Fetch repository metadata from annotations table 140 + metadata, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository) 141 141 if err != nil { 142 142 log.Printf("Failed to fetch repository metadata: %v", err) 143 143 // Continue without metadata on error 144 144 } else { 145 - repo.Title = title 146 - repo.Description = description 147 - repo.SourceURL = sourceURL 148 - repo.DocumentationURL = documentationURL 149 - repo.Licenses = licenses 150 - repo.IconURL = iconURL 151 - repo.ReadmeURL = readmeURL 145 + repo.Title = metadata["org.opencontainers.image.title"] 146 + repo.Description = metadata["org.opencontainers.image.description"] 147 + repo.SourceURL = metadata["org.opencontainers.image.source"] 148 + repo.DocumentationURL = metadata["org.opencontainers.image.documentation"] 149 + repo.Licenses = metadata["org.opencontainers.image.licenses"] 150 + repo.IconURL = metadata["io.atcr.icon"] 151 + repo.ReadmeURL = metadata["io.atcr.readme"] 152 + repo.Version = metadata["org.opencontainers.image.version"] 152 153 } 153 154 154 155 // Fetch star count
+28 -27
pkg/appview/jetstream/processor.go
··· 143 143 if err := json.Unmarshal(recordData, &manifestRecord); err != nil { 144 144 return 0, fmt.Errorf("failed to unmarshal manifest: %w", err) 145 145 } 146 - // Extract OCI annotations from manifest 147 - var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string 148 - if manifestRecord.Annotations != nil { 149 - title = manifestRecord.Annotations["org.opencontainers.image.title"] 150 - description = manifestRecord.Annotations["org.opencontainers.image.description"] 151 - sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"] 152 - documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"] 153 - licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"] 154 - iconURL = manifestRecord.Annotations["io.atcr.icon"] 155 - readmeURL = manifestRecord.Annotations["io.atcr.readme"] 156 - } 157 - 158 146 // Detect manifest type 159 147 isManifestList := len(manifestRecord.Manifests) > 0 160 148 161 - // Prepare manifest for insertion 149 + // Prepare manifest for insertion (WITHOUT annotation fields) 162 150 manifest := &db.Manifest{ 163 - DID: did, 164 - Repository: manifestRecord.Repository, 165 - Digest: manifestRecord.Digest, 166 - MediaType: manifestRecord.MediaType, 167 - SchemaVersion: manifestRecord.SchemaVersion, 168 - HoldEndpoint: manifestRecord.HoldEndpoint, 169 - CreatedAt: manifestRecord.CreatedAt, 170 - Title: title, 171 - Description: description, 172 - SourceURL: sourceURL, 173 - DocumentationURL: documentationURL, 174 - Licenses: licenses, 175 - IconURL: iconURL, 176 - ReadmeURL: readmeURL, 151 + DID: did, 152 + Repository: manifestRecord.Repository, 153 + Digest: manifestRecord.Digest, 154 + MediaType: manifestRecord.MediaType, 155 + SchemaVersion: manifestRecord.SchemaVersion, 156 + HoldEndpoint: manifestRecord.HoldEndpoint, 157 + CreatedAt: manifestRecord.CreatedAt, 158 + // Annotations removed - stored separately in repository_annotations table 177 159 } 178 160 179 161 // Set config fields only for image manifests (not manifest lists) ··· 199 181 manifestID = existingID 200 182 } else { 201 183 return 0, fmt.Errorf("failed to insert manifest: %w", err) 184 + } 185 + } 186 + 187 + // Update repository annotations ONLY if manifest has at least one non-empty annotation 188 + if manifestRecord.Annotations != nil { 189 + hasData := false 190 + for _, value := range manifestRecord.Annotations { 191 + if value != "" { 192 + hasData = true 193 + break 194 + } 195 + } 196 + 197 + if hasData { 198 + // Replace all annotations for this repository 199 + err = db.UpsertRepositoryAnnotations(p.db, did, manifestRecord.Repository, manifestRecord.Annotations) 200 + if err != nil { 201 + return 0, fmt.Errorf("failed to upsert annotations: %w", err) 202 + } 202 203 } 203 204 } 204 205
+6
pkg/appview/static/css/style.css
··· 545 545 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 546 546 } 547 547 548 + .version-badge { 549 + background: #f3e5f5; 550 + color: #7b1fa2; 551 + border: 1px solid #ba68c8; 552 + } 553 + 548 554 .repo-description { 549 555 color: var(--border-dark); 550 556 font-size: 0.95rem;
+6 -1
pkg/appview/templates/pages/repository.html
··· 43 43 </div> 44 44 45 45 <!-- Metadata Section --> 46 - {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }} 46 + {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }} 47 47 <div class="repo-metadata"> 48 + {{ if .Repository.Version }} 49 + <span class="metadata-badge version-badge" title="Version"> 50 + {{ .Repository.Version }} 51 + </span> 52 + {{ end }} 48 53 {{ if .Repository.Licenses }} 49 54 {{ range parseLicenses .Repository.Licenses }} 50 55 {{ if .IsValid }}
+5
pkg/hold/config.go
··· 28 28 // If true, creates/maintains a crew record with memberPattern: "*" (allows all authenticated users) 29 29 // If false, deletes the wildcard crew record if it exists 30 30 AllowAllCrew bool `yaml:"allow_all_crew"` 31 + 32 + // ProfileAvatarURL is the URL to download the avatar image from (from env: HOLD_PROFILE_AVATAR) 33 + // If set, the avatar will be downloaded and uploaded as a blob during bootstrap 34 + ProfileAvatarURL string `yaml:"profile_avatar_url"` 31 35 } 32 36 33 37 // StorageConfig wraps distribution's storage configuration ··· 91 95 // Registration configuration (optional) 92 96 cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER") 93 97 cfg.Registration.AllowAllCrew = os.Getenv("HOLD_ALLOW_ALL_CREW") == "true" 98 + cfg.Registration.ProfileAvatarURL = getEnvOrDefault("HOLD_PROFILE_AVATAR", "https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE") 94 99 95 100 // Database configuration (optional - enables embedded PDS) 96 101 // Note: HOLD_DATABASE_DIR is a directory path, carstore creates db.sqlite3 inside it
+1 -1
pkg/hold/oci/xrpc_test.go
··· 66 66 67 67 // Bootstrap PDS 68 68 ownerDID := "did:plc:owner123" 69 - if err := holdPDS.Bootstrap(ctx, ownerDID, true, false); err != nil { 69 + if err := holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil { 70 70 t.Fatalf("Failed to bootstrap PDS: %v", err) 71 71 } 72 72
+10 -10
pkg/hold/pds/auth_test.go
··· 512 512 holdDID := "did:web:hold01.atcr.io" 513 513 514 514 // Bootstrap with owner 515 - err := pds.Bootstrap(ctx, ownerDID, true, false) 515 + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 516 516 if err != nil { 517 517 t.Fatalf("Failed to bootstrap PDS: %v", err) 518 518 } ··· 546 546 holdDID := "did:web:hold01.atcr.io" 547 547 548 548 // Bootstrap 549 - err := pds.Bootstrap(ctx, ownerDID, true, false) 549 + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 550 550 if err != nil { 551 551 t.Fatalf("Failed to bootstrap PDS: %v", err) 552 552 } ··· 605 605 holdDID := "did:web:hold01.atcr.io" 606 606 607 607 // Bootstrap 608 - err := pds.Bootstrap(ctx, ownerDID, true, false) 608 + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 609 609 if err != nil { 610 610 t.Fatalf("Failed to bootstrap PDS: %v", err) 611 611 } ··· 650 650 ownerDID := "did:plc:owner123" 651 651 652 652 // Bootstrap with owner 653 - err := pds.Bootstrap(ctx, ownerDID, true, false) 653 + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 654 654 if err != nil { 655 655 t.Fatalf("Failed to bootstrap PDS: %v", err) 656 656 } ··· 696 696 ownerDID := "did:plc:owner123" 697 697 698 698 // Bootstrap 699 - err := pds.Bootstrap(ctx, ownerDID, true, false) 699 + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 700 700 if err != nil { 701 701 t.Fatalf("Failed to bootstrap PDS: %v", err) 702 702 } ··· 769 769 ownerDID := "did:plc:owner123" 770 770 771 771 // Bootstrap with public=true 772 - err := pds.Bootstrap(ctx, ownerDID, true, false) 772 + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 773 773 if err != nil { 774 774 t.Fatalf("Failed to bootstrap PDS: %v", err) 775 775 } ··· 806 806 ownerDID := "did:plc:owner123" 807 807 808 808 // Bootstrap with public=false 809 - err := pds.Bootstrap(ctx, ownerDID, false, false) 809 + err := pds.Bootstrap(ctx, nil, ownerDID, false, false, "") 810 810 if err != nil { 811 811 t.Fatalf("Failed to bootstrap PDS: %v", err) 812 812 } ··· 848 848 ownerDID := "did:plc:owner123" 849 849 850 850 // Bootstrap 851 - err := pds.Bootstrap(ctx, ownerDID, true, false) 851 + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 852 852 if err != nil { 853 853 t.Fatalf("Failed to bootstrap PDS: %v", err) 854 854 } ··· 916 916 ownerDID := "did:plc:owner123" 917 917 918 918 // Bootstrap 919 - err := pds.Bootstrap(ctx, ownerDID, true, false) 919 + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 920 920 if err != nil { 921 921 t.Fatalf("Failed to bootstrap PDS: %v", err) 922 922 } ··· 1040 1040 ownerDID := "did:plc:owner123" 1041 1041 1042 1042 // Bootstrap with specified settings 1043 - err := pds.Bootstrap(ctx, ownerDID, tt.public, tt.allowAllCrew) 1043 + err := pds.Bootstrap(ctx, nil, ownerDID, tt.public, tt.allowAllCrew, "") 1044 1044 if err != nil { 1045 1045 t.Fatalf("Failed to bootstrap PDS: %v", err) 1046 1046 }
+174
pkg/hold/pds/profile.go
··· 1 + package pds 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "crypto/sha256" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "time" 11 + 12 + bsky "github.com/bluesky-social/indigo/api/bsky" 13 + lexutil "github.com/bluesky-social/indigo/lex/util" 14 + "github.com/distribution/distribution/v3/registry/storage/driver" 15 + "github.com/ipfs/go-cid" 16 + "github.com/multiformats/go-multihash" 17 + ) 18 + 19 + const ( 20 + // ProfileRkey is the fixed rkey for the profile record (singleton) 21 + ProfileRkey = "self" 22 + 23 + // ProfileCollection is the collection name for Bluesky actor profiles 24 + ProfileCollection = "app.bsky.actor.profile" 25 + ) 26 + 27 + // downloadImage downloads an image from a URL and returns the data and content type 28 + func downloadImage(ctx context.Context, url string) ([]byte, string, error) { 29 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 30 + if err != nil { 31 + return nil, "", fmt.Errorf("failed to create request: %w", err) 32 + } 33 + 34 + client := &http.Client{ 35 + Timeout: 30 * time.Second, 36 + } 37 + 38 + resp, err := client.Do(req) 39 + if err != nil { 40 + return nil, "", fmt.Errorf("failed to download image: %w", err) 41 + } 42 + defer resp.Body.Close() 43 + 44 + if resp.StatusCode != http.StatusOK { 45 + return nil, "", fmt.Errorf("download failed with status %d", resp.StatusCode) 46 + } 47 + 48 + // Read image data 49 + data, err := io.ReadAll(resp.Body) 50 + if err != nil { 51 + return nil, "", fmt.Errorf("failed to read image data: %w", err) 52 + } 53 + 54 + // Get content type from response header 55 + contentType := resp.Header.Get("Content-Type") 56 + if contentType == "" { 57 + contentType = "application/octet-stream" 58 + } 59 + 60 + return data, contentType, nil 61 + } 62 + 63 + // uploadBlobToStorage uploads a blob to the hold's storage and returns a blob reference 64 + // This stores the blob at the ATProto path for the hold's DID 65 + func uploadBlobToStorage(ctx context.Context, storageDriver driver.StorageDriver, did string, data []byte, mimeType string) (*lexutil.LexBlob, error) { 66 + if len(data) == 0 { 67 + return nil, fmt.Errorf("empty blob data") 68 + } 69 + 70 + size := int64(len(data)) 71 + 72 + // Compute SHA-256 hash 73 + hash := sha256.Sum256(data) 74 + 75 + // Create CIDv1 with SHA-256 multihash 76 + mh, err := multihash.EncodeName(hash[:], "sha2-256") 77 + if err != nil { 78 + return nil, fmt.Errorf("failed to encode multihash: %w", err) 79 + } 80 + 81 + // Create CIDv1 with raw codec (0x55) 82 + // ATProto uses CIDv1 with raw codec for blobs 83 + blobCID := cid.NewCidV1(0x55, mh) 84 + 85 + // Store blob via distribution driver at ATProto path 86 + path := atprotoBlobPath(did, blobCID.String()) 87 + 88 + // Write blob to storage using distribution driver 89 + writer, err := storageDriver.Writer(ctx, path, false) 90 + if err != nil { 91 + return nil, fmt.Errorf("failed to create writer: %w", err) 92 + } 93 + 94 + // Write data 95 + n, err := io.Copy(writer, bytes.NewReader(data)) 96 + if err != nil { 97 + writer.Cancel(ctx) 98 + return nil, fmt.Errorf("failed to write blob: %w", err) 99 + } 100 + 101 + // Commit the write 102 + if err := writer.Commit(ctx); err != nil { 103 + return nil, fmt.Errorf("failed to commit blob: %w", err) 104 + } 105 + 106 + if n != size { 107 + return nil, fmt.Errorf("size mismatch: wrote %d bytes, expected %d", n, size) 108 + } 109 + 110 + // Create blob reference in the format expected by bsky.ActorProfile 111 + // LexLink is a type alias for cid.Cid 112 + lexLink := lexutil.LexLink(blobCID) 113 + blob := &lexutil.LexBlob{ 114 + Ref: lexLink, 115 + MimeType: mimeType, 116 + Size: size, 117 + } 118 + 119 + return blob, nil 120 + } 121 + 122 + // CreateProfileRecord creates the app.bsky.actor.profile record for the hold 123 + // This will FAIL if the profile record already exists. 124 + func (p *HoldPDS) CreateProfileRecord(ctx context.Context, storageDriver driver.StorageDriver, displayName, description, avatarURL string) (cid.Cid, error) { 125 + // Create profile struct 126 + profile := &bsky.ActorProfile{ 127 + DisplayName: &displayName, 128 + Description: &description, 129 + } 130 + 131 + // Download and upload avatar if URL is provided 132 + if avatarURL != "" { 133 + fmt.Printf("Downloading avatar from %s\n", avatarURL) 134 + imageData, mimeType, err := downloadImage(ctx, avatarURL) 135 + if err != nil { 136 + return cid.Undef, fmt.Errorf("failed to download avatar: %w", err) 137 + } 138 + 139 + fmt.Printf("Uploading avatar blob (%d bytes, %s)\n", len(imageData), mimeType) 140 + avatarBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, imageData, mimeType) 141 + if err != nil { 142 + return cid.Undef, fmt.Errorf("failed to upload avatar blob: %w", err) 143 + } 144 + 145 + profile.Avatar = avatarBlob 146 + fmt.Printf("Avatar uploaded successfully: %s\n", avatarBlob.Ref.String()) 147 + } 148 + 149 + // Use repomgr.PutRecord - creates with explicit rkey, fails if already exists 150 + recordPath, recordCID, err := p.repomgr.PutRecord(ctx, p.uid, ProfileCollection, ProfileRkey, profile) 151 + if err != nil { 152 + return cid.Undef, fmt.Errorf("failed to create profile record: %w", err) 153 + } 154 + 155 + fmt.Printf("Created profile record at %s, cid: %s\n", recordPath, recordCID) 156 + return recordCID, nil 157 + } 158 + 159 + // GetProfileRecord retrieves the app.bsky.actor.profile record 160 + func (p *HoldPDS) GetProfileRecord(ctx context.Context) (cid.Cid, *bsky.ActorProfile, error) { 161 + // Use repomgr.GetRecord 162 + recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, ProfileCollection, ProfileRkey, cid.Undef) 163 + if err != nil { 164 + return cid.Undef, nil, fmt.Errorf("failed to get profile record: %w", err) 165 + } 166 + 167 + // Type assert to bsky.ActorProfile 168 + profileRecord, ok := val.(*bsky.ActorProfile) 169 + if !ok { 170 + return cid.Undef, nil, fmt.Errorf("unexpected type for profile record: %T", val) 171 + } 172 + 173 + return recordCID, profileRecord, nil 174 + }
+34 -8
pkg/hold/pds/server.go
··· 13 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 14 "github.com/bluesky-social/indigo/models" 15 15 "github.com/bluesky-social/indigo/repo" 16 + "github.com/distribution/distribution/v3/registry/storage/driver" 16 17 "github.com/ipfs/go-cid" 17 18 ) 18 19 ··· 107 108 return p.repomgr 108 109 } 109 110 110 - // Bootstrap initializes the hold with the captain record and owner as first crew member 111 - func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) error { 111 + // Bootstrap initializes the hold with the captain record, owner as first crew member, and profile 112 + func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL string) error { 112 113 if ownerDID == "" { 113 114 return nil 114 115 } 115 116 116 117 // Check if captain record already exists (idempotent bootstrap) 117 118 _, _, err := p.GetCaptainRecord(ctx) 118 - if err == nil { 119 - // Captain record exists, we're good 120 - fmt.Printf("✅ Captain record exists, skipping bootstrap\n") 121 - return nil 119 + captainExists := (err == nil) 120 + 121 + if captainExists { 122 + // Captain record exists, skip captain/crew setup but still create profile if needed 123 + fmt.Printf("✅ Captain record exists, skipping captain/crew setup\n") 124 + } else { 125 + fmt.Printf("🚀 Bootstrapping hold PDS with owner: %s\n", ownerDID) 122 126 } 123 127 124 - fmt.Printf("🚀 Bootstrapping hold PDS with owner: %s\n", ownerDID) 128 + if !captainExists { 125 129 126 130 // Initialize repo if it doesn't exist yet 127 131 // Check if repo exists by trying to get the head ··· 150 154 return fmt.Errorf("failed to add owner as crew member: %w", err) 151 155 } 152 156 153 - fmt.Printf("✅ Added %s as hold admin\n", ownerDID) 157 + fmt.Printf("✅ Added %s as hold admin\n", ownerDID) 158 + } 159 + 160 + // Create profile record (idempotent - check if exists first) 161 + // This runs even if captain exists (for existing holds being upgraded) 162 + // Skip if no storage driver (e.g., in tests) 163 + if storageDriver != nil { 164 + _, _, err = p.GetProfileRecord(ctx) 165 + if err != nil { 166 + // Profile doesn't exist, create it 167 + displayName := "Cargo Hold" 168 + description := "ahoy from the cargo hold" 169 + 170 + _, err = p.CreateProfileRecord(ctx, storageDriver, displayName, description, avatarURL) 171 + if err != nil { 172 + return fmt.Errorf("failed to create profile record: %w", err) 173 + } 174 + fmt.Printf("✅ Created profile record (displayName=%s)\n", displayName) 175 + } else { 176 + fmt.Printf("✅ Profile record already exists, skipping\n") 177 + } 178 + } 179 + 154 180 return nil 155 181 } 156 182
+10 -10
pkg/hold/pds/server_test.go
··· 69 69 70 70 // Bootstrap with a captain record 71 71 ownerDID := "did:plc:owner123" 72 - if err := pds1.Bootstrap(ctx, ownerDID, true, false); err != nil { 72 + if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil { 73 73 t.Fatalf("Bootstrap failed: %v", err) 74 74 } 75 75 ··· 129 129 publicAccess := true 130 130 allowAllCrew := false 131 131 132 - err = pds.Bootstrap(ctx, ownerDID, publicAccess, allowAllCrew) 132 + err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "") 133 133 if err != nil { 134 134 t.Fatalf("Bootstrap failed: %v", err) 135 135 } ··· 204 204 ownerDID := "did:plc:alice123" 205 205 206 206 // First bootstrap 207 - err = pds.Bootstrap(ctx, ownerDID, true, false) 207 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 208 208 if err != nil { 209 209 t.Fatalf("First bootstrap failed: %v", err) 210 210 } ··· 223 223 crewCount1 := len(crew1) 224 224 225 225 // Second bootstrap (should be idempotent - skip creation) 226 - err = pds.Bootstrap(ctx, ownerDID, true, false) 226 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 227 227 if err != nil { 228 228 t.Fatalf("Second bootstrap failed: %v", err) 229 229 } ··· 268 268 defer pds.Close() 269 269 270 270 // Bootstrap with empty owner DID (should be no-op) 271 - err = pds.Bootstrap(ctx, "", true, false) 271 + err = pds.Bootstrap(ctx, nil, "", true, false, "") 272 272 if err != nil { 273 273 t.Fatalf("Bootstrap with empty owner should not error: %v", err) 274 274 } ··· 302 302 303 303 // Bootstrap to create captain record 304 304 ownerDID := "did:plc:alice123" 305 - if err := pds.Bootstrap(ctx, ownerDID, true, false); err != nil { 305 + if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil { 306 306 t.Fatalf("Bootstrap failed: %v", err) 307 307 } 308 308 ··· 355 355 publicAccess := true 356 356 allowAllCrew := false 357 357 358 - err = pds.Bootstrap(ctx, ownerDID, publicAccess, allowAllCrew) 358 + err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "") 359 359 if err != nil { 360 360 t.Fatalf("Bootstrap failed with did:web owner: %v", err) 361 361 } ··· 414 414 415 415 // Bootstrap with did:plc owner 416 416 plcOwner := "did:plc:alice123" 417 - err = pds.Bootstrap(ctx, plcOwner, true, false) 417 + err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "") 418 418 if err != nil { 419 419 t.Fatalf("Bootstrap failed: %v", err) 420 420 } ··· 509 509 } 510 510 511 511 // Bootstrap should create captain record 512 - err = pds.Bootstrap(ctx, ownerDID, true, false) 512 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 513 513 if err != nil { 514 514 t.Fatalf("Bootstrap failed: %v", err) 515 515 } ··· 585 585 586 586 // Bootstrap should be idempotent but notice missing crew 587 587 // Currently Bootstrap skips if captain exists, so crew won't be added 588 - err = pds.Bootstrap(ctx, ownerDID, true, false) 588 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 589 589 if err != nil { 590 590 t.Fatalf("Bootstrap failed: %v", err) 591 591 }
+60 -41
pkg/hold/pds/xrpc.go
··· 795 795 json.NewEncoder(w).Encode(response) 796 796 } 797 797 798 - // HandleGetBlob wraps existing presigned download URL logic 799 - // Supports both ATProto CIDs and OCI sha256 digests 800 - // Authorization: If captain.public = true, open to all. If false, requires crew with blob:read permission. 798 + // HandleGetBlob routes blob requests to appropriate handlers based on blob type 799 + // Routes to: 800 + // - handleGetOCIBlob for OCI image blobs (sha256:...) 801 + // - handleGetATProtoBlob for ATProto blobs (CID format) 801 802 func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) { 802 - log.Printf("[HandleGetBlob] %s request received", r.Method) 803 - 804 803 did := r.URL.Query().Get("did") 805 804 cidOrDigest := r.URL.Query().Get("cid") 806 805 807 - log.Printf("[HandleGetBlob] did=%s, cid=%s", did, cidOrDigest) 806 + log.Printf("[HandleGetBlob] %s request - did=%s, cid=%s", r.Method, did, cidOrDigest) 808 807 809 808 if did == "" || cidOrDigest == "" { 810 809 http.Error(w, "missing required parameters", http.StatusBadRequest) 811 810 return 812 811 } 813 812 814 - // For OCI blobs (sha256:...), skip DID validation since they're content-addressed and globally deduplicated 815 - // For ATProto blobs (CID format), validate DID since they're stored per-DID 816 - if !strings.HasPrefix(cidOrDigest, "sha256:") { 817 - // ATProto blob - validate DID 818 - if did != h.pds.DID() { 819 - log.Printf("[HandleGetBlob] DID mismatch for ATProto blob: got %s, expected %s", did, h.pds.DID()) 820 - http.Error(w, "invalid did", http.StatusBadRequest) 821 - return 822 - } 823 - } else { 824 - // OCI blob - DID doesn't matter, use empty string for content-addressed storage 825 - did = "" 813 + // Route based on blob type 814 + if strings.HasPrefix(cidOrDigest, "sha256:") { 815 + // OCI blob (container image layers) 816 + h.handleGetOCIBlob(w, r, did, cidOrDigest) 817 + return 826 818 } 827 819 828 - // Validate blob read access 820 + // ATProto blob (profile avatars, etc.) 821 + h.handleGetATProtoBlob(w, r, did, cidOrDigest) 822 + } 823 + 824 + // handleGetOCIBlob handles OCI container image blob requests 825 + // Returns JSON with presigned URL for AppView integration 826 + // Authorization: Protected by hold access control (captain.public or crew with blob:read) 827 + func (h *XRPCHandler) handleGetOCIBlob(w http.ResponseWriter, r *http.Request, did, digest string) { 828 + log.Printf("[handleGetOCIBlob] Processing OCI blob: %s", digest) 829 + 830 + // Validate blob read access (hold access control) 829 831 // If captain.public = true, returns nil (public access allowed) 830 832 // If captain.public = false, validates auth and checks for blob:read permission 831 833 _, err := ValidateBlobReadAccess(r, h.pds, h.httpClient) 832 834 if err != nil { 833 - log.Printf("[HandleGetBlob] Authorization failed: %v", err) 835 + log.Printf("[handleGetOCIBlob] Authorization failed: %v", err) 834 836 http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden) 835 837 return 836 838 } 837 839 838 - // Flexible digest parsing: accept both CID and sha256 digest formats 839 - var digest string 840 - if strings.HasPrefix(cidOrDigest, "sha256:") { 841 - // OCI digest format - use directly 842 - digest = cidOrDigest 843 - } else { 844 - // Standard ATProto CID - for ATCR OCI use case, we expect sha256 digests 845 - // If a real CID is provided, we could convert it here, but for now 846 - // we'll just pass it through and let the blob store handle it 847 - digest = cidOrDigest 848 - } 849 - 850 - // Determine presigned URL operation 851 - // Check for ?method=HEAD query parameter first (from AppView), then fall back to request method 852 - // HEAD and GET need different presigned URL signatures 840 + // Determine presigned URL operation (GET or HEAD) 841 + // Check for ?method=HEAD query parameter first (from AppView) 853 842 operation := r.URL.Query().Get("method") 854 843 if operation == "" { 855 844 operation = "GET" 856 845 } 857 846 858 - // Generate presigned URL for the operation 859 - presignedURL, err := h.GetPresignedURL(r.Context(), operation, digest, did) 847 + // Generate presigned URL (use empty DID for content-addressed storage) 848 + presignedURL, err := h.GetPresignedURL(r.Context(), operation, digest, "") 860 849 if err != nil { 861 - log.Printf("[HandleGetBlob] Failed to get presigned %s URL: digest=%s, did=%s, err=%v", operation, digest, did, err) 850 + log.Printf("[handleGetOCIBlob] Failed to get presigned %s URL: %v", operation, err) 862 851 http.Error(w, "failed to get presigned URL", http.StatusInternalServerError) 863 852 return 864 853 } 865 854 866 - log.Printf("[HandleGetBlob] Returning presigned %s URL: %s", operation, presignedURL) 855 + log.Printf("[handleGetOCIBlob] Returning presigned %s URL: %s", operation, presignedURL) 867 856 868 - // Return JSON response with the presigned URL 869 - // AppView will either redirect (GET) or proxy (HEAD) using this URL 857 + // Return JSON response with presigned URL (AppView expects this format) 870 858 response := map[string]string{ 871 859 "url": presignedURL, 872 860 } 873 861 w.Header().Set("Content-Type", "application/json") 874 862 json.NewEncoder(w).Encode(response) 863 + } 864 + 865 + // handleGetATProtoBlob handles standard ATProto blob requests 866 + // Returns 307 redirect to presigned URL (standard ATProto behavior) 867 + // Authorization: Public per ATProto spec (no auth required) 868 + func (h *XRPCHandler) handleGetATProtoBlob(w http.ResponseWriter, r *http.Request, did, cid string) { 869 + log.Printf("[handleGetATProtoBlob] Processing ATProto blob: %s", cid) 870 + 871 + // Validate DID (ATProto blobs are stored per-DID for data sovereignty) 872 + if did != h.pds.DID() { 873 + log.Printf("[handleGetATProtoBlob] DID mismatch: got %s, expected %s", did, h.pds.DID()) 874 + http.Error(w, "invalid did", http.StatusBadRequest) 875 + return 876 + } 877 + 878 + // Determine presigned URL operation (GET or HEAD) 879 + operation := r.URL.Query().Get("method") 880 + if operation == "" { 881 + operation = "GET" 882 + } 883 + 884 + // Generate presigned URL (use DID for per-DID storage path) 885 + presignedURL, err := h.GetPresignedURL(r.Context(), operation, cid, did) 886 + if err != nil { 887 + log.Printf("[handleGetATProtoBlob] Failed to get presigned %s URL: %v", operation, err) 888 + http.Error(w, "failed to get presigned URL", http.StatusInternalServerError) 889 + return 890 + } 891 + 892 + // Return 307 redirect (standard ATProto behavior - client fetches blob directly) 893 + http.Redirect(w, r, presignedURL, http.StatusTemporaryRedirect) 875 894 } 876 895 877 896 // HandleListRepos lists all repositories in this PDS
+25 -36
pkg/hold/pds/xrpc_test.go
··· 46 46 r, w, _ := os.Pipe() 47 47 os.Stdout = w 48 48 49 - err = pds.Bootstrap(ctx, ownerDID, true, false) 49 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 50 50 51 51 // Restore stdout 52 52 w.Close() ··· 1347 1347 r, w, _ := os.Pipe() 1348 1348 os.Stdout = w 1349 1349 1350 - err = pds.Bootstrap(ctx, ownerDID, true, false) 1350 + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") 1351 1351 1352 1352 // Restore stdout 1353 1353 w.Close() ··· 1501 1501 1502 1502 // Tests for HandleGetBlob 1503 1503 1504 - // TestHandleGetBlob tests com.atproto.sync.getBlob 1504 + // TestHandleGetBlob tests com.atproto.sync.getBlob with ATProto CID 1505 + // ATProto blobs should return 307 redirect to presigned URL 1505 1506 // Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob 1506 1507 func TestHandleGetBlob(t *testing.T) { 1507 1508 handler, _, _ := setupTestXRPCHandlerWithBlobs(t) ··· 1517 1518 1518 1519 handler.HandleGetBlob(w, req) 1519 1520 1520 - // Should return 200 OK with JSON response containing presigned URL 1521 - if w.Code != http.StatusOK { 1522 - t.Errorf("Expected status 200 OK, got %d", w.Code) 1521 + // ATProto blob should return 307 Temporary Redirect 1522 + if w.Code != http.StatusTemporaryRedirect { 1523 + t.Errorf("Expected status 307 Temporary Redirect, got %d", w.Code) 1523 1524 } 1524 1525 1525 - // Verify Content-Type is JSON 1526 - contentType := w.Header().Get("Content-Type") 1527 - if contentType != "application/json" { 1528 - t.Errorf("Expected Content-Type application/json, got %s", contentType) 1526 + // Verify Location header exists with presigned URL 1527 + location := w.Header().Get("Location") 1528 + if location == "" { 1529 + t.Error("Expected Location header in 307 redirect") 1529 1530 } 1530 1531 1531 - // Parse JSON response 1532 - var response map[string]string 1533 - if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { 1534 - t.Fatalf("Failed to parse JSON response: %v", err) 1535 - } 1536 - 1537 - // Verify URL field exists (will be XRPC proxy URL since we don't have S3 client) 1538 - if response["url"] == "" { 1539 - t.Error("Expected url field in response") 1532 + // Should be XRPC proxy URL since we don't have S3 client 1533 + if !strings.Contains(location, "/xrpc/com.atproto.sync.getBlob") { 1534 + t.Errorf("Expected XRPC proxy URL, got: %s", location) 1540 1535 } 1541 1536 } 1542 1537 ··· 1589 1584 1590 1585 handler.HandleGetBlob(w, req) 1591 1586 1592 - // Should return 200 OK with JSON response containing presigned HEAD URL 1593 - if w.Code != http.StatusOK { 1594 - t.Errorf("Expected status 200 OK, got %d", w.Code) 1587 + // ATProto blob should return 307 Temporary Redirect (even for HEAD) 1588 + if w.Code != http.StatusTemporaryRedirect { 1589 + t.Errorf("Expected status 307 Temporary Redirect, got %d", w.Code) 1595 1590 } 1596 1591 1597 - // Verify Content-Type is JSON 1598 - contentType := w.Header().Get("Content-Type") 1599 - if contentType != "application/json" { 1600 - t.Errorf("Expected Content-Type application/json, got %s", contentType) 1601 - } 1602 - 1603 - // Parse JSON response 1604 - var response map[string]string 1605 - if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { 1606 - t.Fatalf("Failed to parse JSON response: %v", err) 1592 + // Verify Location header exists with presigned URL 1593 + location := w.Header().Get("Location") 1594 + if location == "" { 1595 + t.Error("Expected Location header in 307 redirect") 1607 1596 } 1608 1597 1609 - // Verify URL field exists (will be XRPC proxy URL since we don't have S3 client) 1610 - if response["url"] == "" { 1611 - t.Error("Expected url field in response") 1598 + // Should be XRPC proxy URL since we don't have S3 client 1599 + if !strings.Contains(location, "/xrpc/com.atproto.sync.getBlob") { 1600 + t.Errorf("Expected XRPC proxy URL, got: %s", location) 1612 1601 } 1613 1602 } 1614 1603 ··· 1800 1789 1801 1790 // Clean up - recreate captain record if it was deleted 1802 1791 if w.Code == http.StatusOK { 1803 - handler.pds.Bootstrap(ctx, "did:plc:testowner123", true, false) 1792 + handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "") 1804 1793 } 1805 1794 } 1806 1795
+112
scripts/test-hold-endpoints.sh
··· 1 + #!/bin/bash 2 + 3 + # Test script for ATProto sync endpoints on hold service 4 + # Usage: ./test-hold-endpoints.sh [HOLD_URL] [DID] 5 + 6 + HOLD_URL="${1:-http://172.28.0.3:8080}" 7 + # You'll need to replace this with your hold's actual DID 8 + DID="${2:-did:web:172.28.0.3%3A8080}" 9 + 10 + echo "Testing ATProto sync endpoints on: $HOLD_URL" 11 + echo "Using DID: $DID" 12 + echo "==========================================" 13 + echo "" 14 + 15 + # Test 1: List repositories 16 + echo "1. Testing com.atproto.sync.listRepos" 17 + echo " GET $HOLD_URL/xrpc/com.atproto.sync.listRepos" 18 + echo " Response:" 19 + curl -s -w "\n HTTP Status: %{http_code}\n" \ 20 + "$HOLD_URL/xrpc/com.atproto.sync.listRepos" | jq . 2>/dev/null || cat 21 + echo "" 22 + echo "==========================================" 23 + echo "" 24 + 25 + # Test 2: Describe repo 26 + echo "2. Testing com.atproto.repo.describeRepo" 27 + echo " GET $HOLD_URL/xrpc/com.atproto.repo.describeRepo?repo=$DID" 28 + echo " Response:" 29 + curl -s -w "\n HTTP Status: %{http_code}\n" \ 30 + "$HOLD_URL/xrpc/com.atproto.repo.describeRepo?repo=$DID" | jq . 2>/dev/null || cat 31 + echo "" 32 + echo "==========================================" 33 + echo "" 34 + 35 + # Test 3: Get repository (CAR file) 36 + echo "3. Testing com.atproto.sync.getRepo" 37 + echo " GET $HOLD_URL/xrpc/com.atproto.sync.getRepo?did=$DID" 38 + echo " Response (showing first 200 bytes of CAR file):" 39 + curl -s -w "\n HTTP Status: %{http_code}\n Content-Type: %{content_type}\n" \ 40 + "$HOLD_URL/xrpc/com.atproto.sync.getRepo?did=$DID" | head -c 200 41 + echo "" 42 + echo " [truncated...]" 43 + echo "" 44 + echo "==========================================" 45 + echo "" 46 + 47 + # Test 4: List records (captain record) 48 + echo "4. Testing com.atproto.repo.listRecords (captain)" 49 + echo " GET $HOLD_URL/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.hold.captain" 50 + echo " Response:" 51 + curl -s -w "\n HTTP Status: %{http_code}\n" \ 52 + "$HOLD_URL/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.hold.captain" | jq . 2>/dev/null || cat 53 + echo "" 54 + echo "==========================================" 55 + echo "" 56 + 57 + # Test 5: List records (crew records) 58 + echo "5. Testing com.atproto.repo.listRecords (crew)" 59 + echo " GET $HOLD_URL/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.hold.crew" 60 + echo " Response:" 61 + curl -s -w "\n HTTP Status: %{http_code}\n" \ 62 + "$HOLD_URL/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.hold.crew" | jq . 2>/dev/null || cat 63 + echo "" 64 + echo "==========================================" 65 + echo "" 66 + 67 + # Test 6: Get blob (this will likely return an error without a valid CID) 68 + echo "6. Testing com.atproto.sync.getBlob (requires valid CID)" 69 + echo " Skipping - needs a real blob CID from your hold" 70 + echo " Example command:" 71 + echo " curl \"$HOLD_URL/xrpc/com.atproto.sync.getBlob?did=$DID&cid=bafyrei...\"" 72 + echo "" 73 + echo "==========================================" 74 + echo "" 75 + 76 + # Test 7: WebSocket - subscribeRepos 77 + echo "7. Testing com.atproto.sync.subscribeRepos (WebSocket)" 78 + echo " This requires a WebSocket client like websocat or wscat" 79 + echo "" 80 + 81 + # Check if websocat is available 82 + if command -v websocat &> /dev/null; then 83 + echo " Found websocat! Connecting for 5 seconds..." 84 + WS_URL="${HOLD_URL/http:/ws:}" 85 + WS_URL="${WS_URL/https:/wss:}" 86 + echo " WS URL: $WS_URL/xrpc/com.atproto.sync.subscribeRepos" 87 + timeout 5 websocat "$WS_URL/xrpc/com.atproto.sync.subscribeRepos" 2>&1 || echo " Connection closed or timeout" 88 + elif command -v wscat &> /dev/null; then 89 + echo " Found wscat! Connecting for 5 seconds..." 90 + WS_URL="${HOLD_URL/http:/ws:}" 91 + WS_URL="${WS_URL/https:/wss:}" 92 + echo " WS URL: $WS_URL/xrpc/com.atproto.sync.subscribeRepos" 93 + timeout 5 wscat -c "$WS_URL/xrpc/com.atproto.sync.subscribeRepos" 2>&1 || echo " Connection closed or timeout" 94 + else 95 + echo " WebSocket client not found. Install websocat or wscat:" 96 + echo " - websocat: cargo install websocat" 97 + echo " - wscat: npm install -g wscat" 98 + echo "" 99 + echo " Manual test command:" 100 + WS_URL="${HOLD_URL/http:/ws:}" 101 + WS_URL="${WS_URL/https:/wss:}" 102 + echo " websocat '$WS_URL/xrpc/com.atproto.sync.subscribeRepos'" 103 + echo " OR" 104 + echo " wscat -c '$WS_URL/xrpc/com.atproto.sync.subscribeRepos'" 105 + fi 106 + 107 + echo "" 108 + echo "==========================================" 109 + echo "Tests complete!" 110 + echo "" 111 + echo "Note: If you need to test with a specific blob, first push an image" 112 + echo "to get real blob CIDs, then use them with getBlob endpoint."