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

Configure Feed

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

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."