From 3e9a496a5d0b7ae63217eab013e571b5bdc3c7a9 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Wed, 22 Oct 2025 11:08:13 -0500 Subject: [PATCH] refactor how annotations are stored. add ability to create bsky profile for hold user --- cmd/hold/main.go | 11 +- docs/ANNOTATIONS_REFACTOR.md | 128 +------ docs/HOLD_ENDPOINT_TESTS.md | 218 ++++++++++++ pkg/appview/db/annotations.go | 78 +++++ .../0003_add_readme_url_to_manifests.yaml | 51 +-- ...ove_annotation_columns_from_manifests.yaml | 35 ++ pkg/appview/db/models.go | 33 +- pkg/appview/db/queries.go | 330 ++++-------------- pkg/appview/db/queries_test.go | 246 ++++++++----- pkg/appview/db/schema.sql | 19 +- pkg/appview/handlers/repository.go | 19 +- pkg/appview/jetstream/processor.go | 55 +-- pkg/appview/static/css/style.css | 6 + pkg/appview/templates/pages/repository.html | 7 +- pkg/hold/config.go | 5 + pkg/hold/oci/xrpc_test.go | 2 +- pkg/hold/pds/auth_test.go | 20 +- pkg/hold/pds/profile.go | 174 +++++++++ pkg/hold/pds/server.go | 42 ++- pkg/hold/pds/server_test.go | 20 +- pkg/hold/pds/xrpc.go | 101 +++--- pkg/hold/pds/xrpc_test.go | 61 ++-- scripts/test-hold-endpoints.sh | 112 ++++++ 23 files changed, 1080 insertions(+), 693 deletions(-) create mode 100644 docs/HOLD_ENDPOINT_TESTS.md create mode 100644 pkg/appview/db/annotations.go create mode 100644 pkg/appview/db/migrations/0004_remove_annotation_columns_from_manifests.yaml create mode 100644 pkg/hold/pds/profile.go create mode 100755 scripts/test-hold-endpoints.sh diff --git a/cmd/hold/main.go b/cmd/hold/main.go index 8b3261e..94cee71 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -43,8 +43,15 @@ func main() { log.Fatalf("Failed to initialize embedded PDS: %v", err) } - // Bootstrap PDS with captain record and hold owner as first crew member - if err := holdPDS.Bootstrap(ctx, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew); err != nil { + // Create storage driver from config (needed for bootstrap profile avatar) + driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters()) + if err != nil { + log.Fatalf("failed to create storage driver: %v", err) + return + } + + // Bootstrap PDS with captain record, hold owner as first crew member, and profile + if err := holdPDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL); err != nil { log.Fatalf("Failed to bootstrap PDS: %v", err) } diff --git a/docs/ANNOTATIONS_REFACTOR.md b/docs/ANNOTATIONS_REFACTOR.md index b0e3876..dd20a7b 100644 --- a/docs/ANNOTATIONS_REFACTOR.md +++ b/docs/ANNOTATIONS_REFACTOR.md @@ -61,133 +61,7 @@ Keep only core manifest metadata: ## Migration Strategy -### Migration File: `0004_refactor_annotations_table.yaml` - -```yaml -description: Migrate manifest annotations to separate table -query: | - -- Step 1: Create new annotations table - CREATE TABLE IF NOT EXISTS repository_annotations ( - did TEXT NOT NULL, - repository TEXT NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY(did, repository, key), - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository); - CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key); - - -- Step 2: Migrate existing data from manifests to annotations - -- For each repository, use the most recent manifest with non-empty data - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) - SELECT - m.did, - m.repository, - 'org.opencontainers.image.title' as key, - m.title as value, - m.created_at as updated_at - FROM manifests m - WHERE m.title IS NOT NULL AND m.title != '' - AND m.created_at = ( - SELECT MAX(created_at) FROM manifests m2 - WHERE m2.did = m.did AND m2.repository = m.repository - AND m2.title IS NOT NULL AND m2.title != '' - ); - - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) - SELECT m.did, m.repository, 'org.opencontainers.image.description', m.description, m.created_at - FROM manifests m - WHERE m.description IS NOT NULL AND m.description != '' - AND m.created_at = ( - SELECT MAX(created_at) FROM manifests m2 - WHERE m2.did = m.did AND m2.repository = m.repository - AND m2.description IS NOT NULL AND m2.description != '' - ); - - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) - SELECT m.did, m.repository, 'org.opencontainers.image.source', m.source_url, m.created_at - FROM manifests m - WHERE m.source_url IS NOT NULL AND m.source_url != '' - AND m.created_at = ( - SELECT MAX(created_at) FROM manifests m2 - WHERE m2.did = m.did AND m2.repository = m.repository - AND m2.source_url IS NOT NULL AND m2.source_url != '' - ); - - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) - SELECT m.did, m.repository, 'org.opencontainers.image.documentation', m.documentation_url, m.created_at - FROM manifests m - WHERE m.documentation_url IS NOT NULL AND m.documentation_url != '' - AND m.created_at = ( - SELECT MAX(created_at) FROM manifests m2 - WHERE m2.did = m.did AND m2.repository = m.repository - AND m2.documentation_url IS NOT NULL AND m2.documentation_url != '' - ); - - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) - SELECT m.did, m.repository, 'org.opencontainers.image.licenses', m.licenses, m.created_at - FROM manifests m - WHERE m.licenses IS NOT NULL AND m.licenses != '' - AND m.created_at = ( - SELECT MAX(created_at) FROM manifests m2 - WHERE m2.did = m.did AND m2.repository = m.repository - AND m2.licenses IS NOT NULL AND m2.licenses != '' - ); - - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) - SELECT m.did, m.repository, 'io.atcr.icon', m.icon_url, m.created_at - FROM manifests m - WHERE m.icon_url IS NOT NULL AND m.icon_url != '' - AND m.created_at = ( - SELECT MAX(created_at) FROM manifests m2 - WHERE m2.did = m.did AND m2.repository = m.repository - AND m2.icon_url IS NOT NULL AND m2.icon_url != '' - ); - - INSERT OR REPLACE INTO repository_annotations (did, repository, key, value, updated_at) - SELECT m.did, m.repository, 'io.atcr.readme', m.readme_url, m.created_at - FROM manifests m - WHERE m.readme_url IS NOT NULL AND m.readme_url != '' - AND m.created_at = ( - SELECT MAX(created_at) FROM manifests m2 - WHERE m2.did = m.did AND m2.repository = m.repository - AND m2.readme_url IS NOT NULL AND m2.readme_url != '' - ); - - -- Step 3: Drop old columns from manifests table - -- SQLite requires recreating table to drop columns - CREATE TABLE manifests_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - did TEXT NOT NULL, - repository TEXT NOT NULL, - digest TEXT NOT NULL, - hold_endpoint TEXT NOT NULL, - schema_version INTEGER NOT NULL, - media_type TEXT NOT NULL, - config_digest TEXT, - config_size INTEGER, - created_at TIMESTAMP NOT NULL, - UNIQUE(did, repository, digest), - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE - ); - - -- Copy data to new table - INSERT INTO manifests_new - SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, - config_digest, config_size, created_at - FROM manifests; - - -- Replace old table - DROP TABLE manifests; - ALTER TABLE manifests_new RENAME TO manifests; - - -- Recreate indexes - CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); - CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); -``` +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. ## Code Changes diff --git a/docs/HOLD_ENDPOINT_TESTS.md b/docs/HOLD_ENDPOINT_TESTS.md new file mode 100644 index 0000000..edf4da4 --- /dev/null +++ b/docs/HOLD_ENDPOINT_TESTS.md @@ -0,0 +1,218 @@ +# Hold Service Endpoint Testing Guide + +## Quick Reference + +Your hold service: `http://172.28.0.3:8080` + +Default DID format for local testing: `did:web:172.28.0.3%3A8080` (URL-encoded `did:web:172.28.0.3:8080`) + +## Individual cURL Commands + +### 1. List Repositories +```bash +curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.listRepos" | jq . +``` + +**Expected response:** +```json +{ + "repos": [ + { + "did": "did:web:172.28.0.3%3A8080", + "head": "...", + "rev": "..." + } + ] +} +``` + +### 2. Describe Repository +```bash +curl -s "http://172.28.0.3:8080/xrpc/com.atproto.repo.describeRepo?repo=did:web:172.28.0.3%3A8080" | jq . +``` + +**Expected response:** +```json +{ + "did": "did:web:172.28.0.3%3A8080", + "handle": "172.28.0.3:8080", + "didDoc": {...}, + "collections": ["io.atcr.hold.captain", "io.atcr.hold.crew"] +} +``` + +### 3. Get Repository (CAR file) +```bash +# Download entire repo as CAR file +curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.getRepo?did=did:web:172.28.0.3%3A8080" -o repo.car + +# Get repo diff since revision +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 +``` + +**Expected response:** Binary CAR (Content Addressable aRchive) file + +### 4. List Captain Records +```bash +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 . +``` + +**Expected response:** +```json +{ + "records": [ + { + "uri": "at://did:web:172.28.0.3%3A8080/io.atcr.hold.captain/self", + "cid": "...", + "value": { + "$type": "io.atcr.hold.captain", + "allowAllCrew": true, + "public": false, + "createdAt": "2025-10-22T..." + } + } + ] +} +``` + +### 5. List Crew Records +```bash +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 . +``` + +**Expected response:** +```json +{ + "records": [ + { + "uri": "at://did:web:172.28.0.3%3A8080/io.atcr.hold.crew/{rkey}", + "cid": "...", + "value": { + "$type": "io.atcr.hold.crew", + "did": "did:plc:...", + "permissions": ["blob:read", "blob:write"], + "createdAt": "2025-10-22T..." + } + } + ] +} +``` + +### 6. Get Specific Record +```bash +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 . +``` + +### 7. Get Blob +```bash +# Replace with actual CID from your hold +curl -s "http://172.28.0.3:8080/xrpc/com.atproto.sync.getBlob?did=did:web:172.28.0.3%3A8080&cid=bafyreiabc123..." | jq . +``` + +**Expected response (for OCI blobs):** +```json +{ + "url": "https://s3.amazonaws.com/bucket/path?presigned-params...", + "expiresAt": "2025-10-22T12:15:00Z" +} +``` + +### 8. Subscribe to Repository Events (WebSocket) + +Using **websocat** (recommended): +```bash +# Install: cargo install websocat +websocat "ws://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos" +``` + +Using **wscat**: +```bash +# Install: npm install -g wscat +wscat -c "ws://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos" +``` + +Using **curl** (HTTP upgrade - may not work with all servers): +```bash +curl -i -N \ + -H "Connection: Upgrade" \ + -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Version: 13" \ + -H "Sec-WebSocket-Key: $(echo -n "test" | base64)" \ + "http://172.28.0.3:8080/xrpc/com.atproto.sync.subscribeRepos" +``` + +**Expected response:** Stream of CBOR-encoded events (commits, identities, handles, etc.) + +## DID Resolution + +### Get DID Document +```bash +curl -s "http://172.28.0.3:8080/.well-known/did.json" | jq . +``` + +**Expected response:** +```json +{ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": "did:web:172.28.0.3%3A8080", + "service": [ + { + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": "http://172.28.0.3:8080" + } + ] +} +``` + +### Get DID from Handle +```bash +curl -s "http://172.28.0.3:8080/.well-known/atproto-did" +``` + +**Expected response:** Plain text DID +``` +did:web:172.28.0.3%3A8080 +``` + +## Running the Test Script + +```bash +# Default (uses 172.28.0.3:8080) +./test-hold-endpoints.sh + +# Custom hold URL +./test-hold-endpoints.sh "http://localhost:8080" + +# Custom hold URL and DID +./test-hold-endpoints.sh "http://localhost:8080" "did:web:localhost%3A8080" +``` + +## Troubleshooting + +### "Connection refused" +- Ensure hold service is running: `docker ps` or check process +- Verify IP address: `docker inspect | grep IPAddress` + +### "Empty response" or "404 Not Found" +- Check hold service logs for errors +- Verify DID format (use URL-encoded version with `%3A` for `:`) +- Ensure hold has been initialized (should have captain record) + +### WebSocket connection fails +- Install websocat: `cargo install websocat` +- Or install wscat: `npm install -g wscat` +- WebSocket endpoints only work with proper WS clients, not regular curl + +### "No records found" +- Captain record created on hold startup if `HOLD_OWNER` is set +- Crew records created when users call `io.atcr.hold.requestCrew` +- Blobs only exist after pushing container images + +## Next Steps + +After verifying these endpoints work: +1. Test OCI upload endpoints (requires authentication) +2. Push a real container image to create blob data +3. Test blob retrieval with real CIDs +4. Monitor WebSocket events during pushes diff --git a/pkg/appview/db/annotations.go b/pkg/appview/db/annotations.go new file mode 100644 index 0000000..0d43688 --- /dev/null +++ b/pkg/appview/db/annotations.go @@ -0,0 +1,78 @@ +package db + +import ( + "database/sql" + "time" +) + +// GetRepositoryAnnotations retrieves all annotations for a repository +func GetRepositoryAnnotations(db *sql.DB, did, repository string) (map[string]string, error) { + rows, err := db.Query(` + SELECT key, value + FROM repository_annotations + WHERE did = ? AND repository = ? + `, did, repository) + if err != nil { + return nil, err + } + defer rows.Close() + + annotations := make(map[string]string) + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + return nil, err + } + annotations[key] = value + } + + return annotations, rows.Err() +} + +// UpsertRepositoryAnnotations replaces all annotations for a repository +// Only called when manifest has at least one non-empty annotation +func UpsertRepositoryAnnotations(db *sql.DB, did, repository string, annotations map[string]string) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Delete existing annotations + _, err = tx.Exec(` + DELETE FROM repository_annotations + WHERE did = ? AND repository = ? + `, did, repository) + if err != nil { + return err + } + + // Insert new annotations + stmt, err := tx.Prepare(` + INSERT INTO repository_annotations (did, repository, key, value, updated_at) + VALUES (?, ?, ?, ?, ?) + `) + if err != nil { + return err + } + defer stmt.Close() + + now := time.Now() + for key, value := range annotations { + _, err = stmt.Exec(did, repository, key, value, now) + if err != nil { + return err + } + } + + return tx.Commit() +} + +// DeleteRepositoryAnnotations removes all annotations for a repository +func DeleteRepositoryAnnotations(db *sql.DB, did, repository string) error { + _, err := db.Exec(` + DELETE FROM repository_annotations + WHERE did = ? AND repository = ? + `, did, repository) + return err +} diff --git a/pkg/appview/db/migrations/0003_add_readme_url_to_manifests.yaml b/pkg/appview/db/migrations/0003_add_readme_url_to_manifests.yaml index 7f4e332..32d73a3 100644 --- a/pkg/appview/db/migrations/0003_add_readme_url_to_manifests.yaml +++ b/pkg/appview/db/migrations/0003_add_readme_url_to_manifests.yaml @@ -1,46 +1,7 @@ -description: Add readme_url column to manifests table (idempotent - handles both fresh and existing databases) +description: Add readme_url to manifests (obsolete - kept for migration history) query: | - -- Idempotent migration: adds readme_url column if it doesn't exist - -- Works for both fresh installs (where schema.sql created it) and existing databases - - -- Create temp table with new schema - CREATE TABLE manifests_temp ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - did TEXT NOT NULL, - repository TEXT NOT NULL, - digest TEXT NOT NULL, - hold_endpoint TEXT NOT NULL, - schema_version INTEGER NOT NULL, - media_type TEXT NOT NULL, - config_digest TEXT, - config_size INTEGER, - created_at TIMESTAMP NOT NULL, - title TEXT, - description TEXT, - source_url TEXT, - documentation_url TEXT, - licenses TEXT, - icon_url TEXT, - readme_url TEXT, - UNIQUE(did, repository, digest), - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE - ); - - -- Copy data from existing manifests table - -- Use INSERT OR IGNORE to handle case where table is already correct - INSERT OR IGNORE INTO manifests_temp - SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, - config_digest, config_size, created_at, title, description, source_url, - documentation_url, licenses, icon_url, - NULL as readme_url -- Will be NULL for existing data - FROM manifests; - - -- Only proceed with table swap if we actually copied data - -- (manifests_temp will be empty if manifests table already has readme_url) - DROP TABLE IF EXISTS manifests; - ALTER TABLE manifests_temp RENAME TO manifests; - - -- Recreate indexes - CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); - CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); + -- This migration is obsolete. The readme_url and other annotations + -- are now stored in the repository_annotations table (see schema.sql). + -- Backfill will populate annotation data from PDS records. + -- This migration is kept as a no-op to maintain migration history. + SELECT 1; diff --git a/pkg/appview/db/migrations/0004_remove_annotation_columns_from_manifests.yaml b/pkg/appview/db/migrations/0004_remove_annotation_columns_from_manifests.yaml new file mode 100644 index 0000000..9e0713a --- /dev/null +++ b/pkg/appview/db/migrations/0004_remove_annotation_columns_from_manifests.yaml @@ -0,0 +1,35 @@ +description: Remove annotation columns from manifests table +query: | + -- Drop annotation columns from manifests table (if they exist) + -- Annotations are now stored in repository_annotations table + -- SQLite doesn't support DROP COLUMN IF EXISTS, so we recreate the table + + -- Create new manifests table without annotation columns + CREATE TABLE IF NOT EXISTS manifests_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + did TEXT NOT NULL, + repository TEXT NOT NULL, + digest TEXT NOT NULL, + hold_endpoint TEXT NOT NULL, + schema_version INTEGER NOT NULL, + media_type TEXT NOT NULL, + config_digest TEXT, + config_size INTEGER, + created_at TIMESTAMP NOT NULL, + UNIQUE(did, repository, digest), + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE + ); + + -- Copy data (only core fields, annotation columns are dropped) + INSERT INTO manifests_new (id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at) + SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at + FROM manifests; + + -- Swap tables + DROP TABLE manifests; + ALTER TABLE manifests_new RENAME TO manifests; + + -- Recreate indexes + CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); + CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); diff --git a/pkg/appview/db/models.go b/pkg/appview/db/models.go index f14a590..175a449 100644 --- a/pkg/appview/db/models.go +++ b/pkg/appview/db/models.go @@ -13,27 +13,17 @@ type User struct { // Manifest represents an OCI manifest stored in the cache type Manifest struct { - ID int64 - DID string - Repository string - Digest string - HoldEndpoint string - SchemaVersion int - MediaType string - ConfigDigest string - ConfigSize int64 - CreatedAt time.Time - Title string - Description string - SourceURL string - DocumentationURL string - Licenses string - IconURL string - ReadmeURL string - PlatformOS string // UNUSED: Reserved for future use, always NULL - PlatformArchitecture string // UNUSED: Reserved for future use, always NULL - PlatformVariant string // UNUSED: Reserved for future use, always NULL - PlatformOSVersion string // UNUSED: Reserved for future use, always NULL + ID int64 + DID string + Repository string + Digest string + HoldEndpoint string + SchemaVersion int + MediaType string + ConfigDigest string + ConfigSize int64 + CreatedAt time.Time + // Annotations removed - now stored in repository_annotations table } // Layer represents a layer in a manifest @@ -100,6 +90,7 @@ type Repository struct { Licenses string IconURL string ReadmeURL string + Version string } // RepositoryStats represents statistics for a repository diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 196b6c7..ebd4dae 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -39,9 +39,9 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, t.repository, t.tag, t.digest, - COALESCE(m.title, ''), - COALESCE(m.description, ''), - COALESCE(m.icon_url, ''), + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.title'), ''), + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.description'), ''), + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''), COALESCE(rs.pull_count, 0), COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), t.created_at, @@ -94,7 +94,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, return pushes, total, nil } -// SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and manifest annotations +// SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and annotations func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, error) { // Escape LIKE wildcards so they're treated literally query = escapeLikePattern(query) @@ -109,9 +109,9 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err t.repository, t.tag, t.digest, - COALESCE(m.title, ''), - COALESCE(m.description, ''), - COALESCE(m.icon_url, ''), + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.title'), ''), + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'org.opencontainers.image.description'), ''), + COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''), COALESCE(rs.pull_count, 0), COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), t.created_at, @@ -123,13 +123,16 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err WHERE u.handle LIKE ? ESCAPE '\' OR u.did = ? OR t.repository LIKE ? ESCAPE '\' - OR m.title LIKE ? ESCAPE '\' - OR m.description LIKE ? ESCAPE '\' + OR EXISTS ( + SELECT 1 FROM repository_annotations ra + WHERE ra.did = u.did AND ra.repository = t.repository + AND ra.value LIKE ? ESCAPE '\' + ) ORDER BY t.created_at DESC LIMIT ? OFFSET ? ` - rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, searchPattern, limit, offset) + rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, limit, offset) if err != nil { return nil, 0, err } @@ -153,12 +156,15 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err WHERE u.handle LIKE ? ESCAPE '\' OR u.did = ? OR t.repository LIKE ? ESCAPE '\' - OR m.title LIKE ? ESCAPE '\' - OR m.description LIKE ? ESCAPE '\' + OR EXISTS ( + SELECT 1 FROM repository_annotations ra + WHERE ra.did = u.did AND ra.repository = t.repository + AND ra.value LIKE ? ESCAPE '\' + ) ` var total int - if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern, searchPattern).Scan(&total); err != nil { + if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil { return nil, 0, err } @@ -242,8 +248,7 @@ func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) { // Get manifests for this repo manifestRows, err := db.Query(` SELECT id, digest, hold_endpoint, schema_version, media_type, - config_digest, config_size, created_at, - title, description, source_url, documentation_url, licenses, icon_url + config_digest, config_size, created_at FROM manifests WHERE did = ? AND repository = ? ORDER BY created_at DESC @@ -258,123 +263,40 @@ func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) { m.DID = did m.Repository = r.Name - // Use sql.NullString for nullable annotation fields - var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString - if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, - &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt, - &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil { + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt); err != nil { manifestRows.Close() return nil, err } - // Convert NullString to string - if title.Valid { - m.Title = title.String - } - if description.Valid { - m.Description = description.String - } - if sourceURL.Valid { - m.SourceURL = sourceURL.String - } - if documentationURL.Valid { - m.DocumentationURL = documentationURL.String - } - if licenses.Valid { - m.Licenses = licenses.String - } - if iconURL.Valid { - m.IconURL = iconURL.String - } - r.Manifests = append(r.Manifests, m) } manifestRows.Close() - // Aggregate repository-level annotations from most recent manifest - if len(r.Manifests) > 0 { - latest := r.Manifests[0] - r.Title = latest.Title - r.Description = latest.Description - r.SourceURL = latest.SourceURL - r.DocumentationURL = latest.DocumentationURL - r.Licenses = latest.Licenses - r.IconURL = latest.IconURL + // Fetch repository-level annotations from annotations table + annotations, err := GetRepositoryAnnotations(db, did, r.Name) + if err != nil { + return nil, err } + r.Title = annotations["org.opencontainers.image.title"] + r.Description = annotations["org.opencontainers.image.description"] + r.SourceURL = annotations["org.opencontainers.image.source"] + r.DocumentationURL = annotations["org.opencontainers.image.documentation"] + r.Licenses = annotations["org.opencontainers.image.licenses"] + r.IconURL = annotations["io.atcr.icon"] + r.ReadmeURL = annotations["io.atcr.readme"] + repos = append(repos, r) } return repos, nil } -// GetRepositoryMetadata retrieves metadata for a repository from its most recent manifest -// Prioritizes manifests with non-empty metadata fields -func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string, err error) { - var titleNull, descriptionNull, sourceURLNull, documentationURLNull, licensesNull, iconURLNull, readmeURLNull sql.NullString - - // Try to find a manifest with metadata first (prefer manifests with any non-empty annotation field) - err = db.QueryRow(` - SELECT title, description, source_url, documentation_url, licenses, icon_url, readme_url - FROM manifests - WHERE did = ? AND repository = ? - AND ( - (title IS NOT NULL AND title != '') - OR (description IS NOT NULL AND description != '') - OR (source_url IS NOT NULL AND source_url != '') - OR (documentation_url IS NOT NULL AND documentation_url != '') - OR (licenses IS NOT NULL AND licenses != '') - OR (icon_url IS NOT NULL AND icon_url != '') - OR (readme_url IS NOT NULL AND readme_url != '') - ) - ORDER BY created_at DESC - LIMIT 1 - `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull, &readmeURLNull) - - // If no manifest with metadata found, fall back to latest manifest (any type) - if err == sql.ErrNoRows { - err = db.QueryRow(` - SELECT title, description, source_url, documentation_url, licenses, icon_url, readme_url - FROM manifests - WHERE did = ? AND repository = ? - ORDER BY created_at DESC - LIMIT 1 - `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull, &readmeURLNull) - } - - if err == sql.ErrNoRows { - // No manifests found - return empty strings - return "", "", "", "", "", "", "", nil - } - if err != nil { - return "", "", "", "", "", "", "", err - } - - // Convert NullString to string - if titleNull.Valid { - title = titleNull.String - } - if descriptionNull.Valid { - description = descriptionNull.String - } - if sourceURLNull.Valid { - sourceURL = sourceURLNull.String - } - if documentationURLNull.Valid { - documentationURL = documentationURLNull.String - } - if licensesNull.Valid { - licenses = licensesNull.String - } - if iconURLNull.Valid { - iconURL = iconURLNull.String - } - if readmeURLNull.Valid { - readmeURL = readmeURLNull.String - } - - return title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, nil +// GetRepositoryMetadata retrieves metadata for a repository from annotations table +// Returns a map of annotation key -> value for easy access in templates and handlers +func GetRepositoryMetadata(db *sql.DB, did string, repository string) (map[string]string, error) { + return GetRepositoryAnnotations(db, did, repository) } // GetUserByDID retrieves a user by DID @@ -555,33 +477,24 @@ func DeleteTagsNotInList(db *sql.DB, did string, keepTags []struct{ Repository, } // InsertManifest inserts or updates a manifest record -// Uses UPSERT to update labels/annotations if manifest already exists +// Uses UPSERT to update core metadata if manifest already exists // Returns the manifest ID (works correctly for both insert and update) +// Note: Annotations are stored separately in repository_annotations table func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) { _, err := db.Exec(` INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, - config_digest, config_size, created_at, - title, description, source_url, documentation_url, licenses, icon_url, readme_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + config_digest, config_size, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(did, repository, digest) DO UPDATE SET hold_endpoint = excluded.hold_endpoint, schema_version = excluded.schema_version, media_type = excluded.media_type, config_digest = excluded.config_digest, - config_size = excluded.config_size, - title = excluded.title, - description = excluded.description, - source_url = excluded.source_url, - documentation_url = excluded.documentation_url, - licenses = excluded.licenses, - icon_url = excluded.icon_url, - readme_url = excluded.readme_url + config_size = excluded.config_size `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, - manifest.ConfigSize, manifest.CreatedAt, - manifest.Title, manifest.Description, manifest.SourceURL, - manifest.DocumentationURL, manifest.Licenses, manifest.IconURL, manifest.ReadmeURL) + manifest.ConfigSize, manifest.CreatedAt) if err != nil { return 0, err @@ -719,50 +632,23 @@ func DeleteManifest(db *sql.DB, did, repository, digest string) error { } // GetManifest fetches a single manifest by digest +// Note: Annotations are stored separately in repository_annotations table func GetManifest(db *sql.DB, digest string) (*Manifest, error) { var m Manifest - // Use sql.NullString for nullable annotation fields - var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL sql.NullString - err := db.QueryRow(` SELECT id, did, repository, digest, hold_endpoint, schema_version, - media_type, config_digest, config_size, created_at, - title, description, source_url, documentation_url, licenses, icon_url, readme_url + media_type, config_digest, config_size, created_at FROM manifests WHERE digest = ? `, digest).Scan(&m.ID, &m.DID, &m.Repository, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, &m.MediaType, &m.ConfigDigest, &m.ConfigSize, - &m.CreatedAt, - &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL, &readmeURL) + &m.CreatedAt) if err != nil { return nil, err } - // Convert NullString to string - if title.Valid { - m.Title = title.String - } - if description.Valid { - m.Description = description.String - } - if sourceURL.Valid { - m.SourceURL = sourceURL.String - } - if documentationURL.Valid { - m.DocumentationURL = documentationURL.String - } - if licenses.Valid { - m.Licenses = licenses.String - } - if iconURL.Valid { - m.IconURL = iconURL.String - } - if readmeURL.Valid { - m.ReadmeURL = readmeURL.String - } - return &m, nil } @@ -855,6 +741,7 @@ func GetManifestReferencesForManifest(db *sql.DB, manifestID int64) ([]ManifestR // GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests // Filters out platform-specific manifests that are referenced by manifest lists +// Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int) ([]ManifestWithMetadata, error) { rows, err := db.Query(` WITH manifest_list_children AS ( @@ -866,8 +753,7 @@ func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int) ) SELECT m.id, m.did, m.repository, m.digest, m.media_type, - m.schema_version, m.created_at, m.title, m.description, - m.source_url, m.documentation_url, m.licenses, m.icon_url, + m.schema_version, m.created_at, m.config_digest, m.config_size, m.hold_endpoint, GROUP_CONCAT(DISTINCT t.tag) as tags, COUNT(DISTINCT mr.digest) as platform_count @@ -895,13 +781,12 @@ func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int) var manifests []ManifestWithMetadata for rows.Next() { var m ManifestWithMetadata - var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString + var tags, configDigest sql.NullString var configSize sql.NullInt64 if err := rows.Scan( &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType, - &m.SchemaVersion, &m.CreatedAt, &title, &description, - &sourceURL, &documentationURL, &licenses, &iconURL, + &m.SchemaVersion, &m.CreatedAt, &configDigest, &configSize, &m.HoldEndpoint, &tags, &m.PlatformCount, ); err != nil { @@ -909,24 +794,6 @@ func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int) } // Set nullable fields - if title.Valid { - m.Title = title.String - } - if description.Valid { - m.Description = description.String - } - if sourceURL.Valid { - m.SourceURL = sourceURL.String - } - if documentationURL.Valid { - m.DocumentationURL = documentationURL.String - } - if licenses.Valid { - m.Licenses = licenses.String - } - if iconURL.Valid { - m.IconURL = iconURL.String - } if configDigest.Valid { m.ConfigDigest = configDigest.String } @@ -998,17 +865,17 @@ func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int) } // GetManifestDetail returns a manifest with full platform details and tags +// Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them func GetManifestDetail(db *sql.DB, did, repository, digest string) (*ManifestWithMetadata, error) { // First, get the manifest and its tags var m ManifestWithMetadata - var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString + var tags, configDigest sql.NullString var configSize sql.NullInt64 err := db.QueryRow(` SELECT m.id, m.did, m.repository, m.digest, m.media_type, - m.schema_version, m.created_at, m.title, m.description, - m.source_url, m.documentation_url, m.licenses, m.icon_url, + m.schema_version, m.created_at, m.config_digest, m.config_size, m.hold_endpoint, GROUP_CONCAT(DISTINCT t.tag) as tags FROM manifests m @@ -1017,8 +884,7 @@ func GetManifestDetail(db *sql.DB, did, repository, digest string) (*ManifestWit GROUP BY m.id `, did, repository, digest).Scan( &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType, - &m.SchemaVersion, &m.CreatedAt, &title, &description, - &sourceURL, &documentationURL, &licenses, &iconURL, + &m.SchemaVersion, &m.CreatedAt, &configDigest, &configSize, &m.HoldEndpoint, &tags, ) @@ -1031,24 +897,6 @@ func GetManifestDetail(db *sql.DB, did, repository, digest string) (*ManifestWit } // Set nullable fields - if title.Valid { - m.Title = title.String - } - if description.Valid { - m.Description = description.String - } - if sourceURL.Valid { - m.SourceURL = sourceURL.String - } - if documentationURL.Valid { - m.DocumentationURL = documentationURL.String - } - if licenses.Valid { - m.Licenses = licenses.String - } - if iconURL.Valid { - m.IconURL = iconURL.String - } if configDigest.Valid { m.ConfigDigest = configDigest.String } @@ -1303,8 +1151,7 @@ func GetRepository(db *sql.DB, did, repository string) (*Repository, error) { // Get manifests for this repo manifestRows, err := db.Query(` SELECT id, digest, hold_endpoint, schema_version, media_type, - config_digest, config_size, created_at, - title, description, source_url, documentation_url, licenses, icon_url + config_digest, config_size, created_at FROM manifests WHERE did = ? AND repository = ? ORDER BY created_at DESC @@ -1319,51 +1166,30 @@ func GetRepository(db *sql.DB, did, repository string) (*Repository, error) { m.DID = did m.Repository = repository - // Use sql.NullString for nullable annotation fields - var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString - if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, - &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt, - &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil { + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt); err != nil { manifestRows.Close() return nil, err } - // Convert NullString to string - if title.Valid { - m.Title = title.String - } - if description.Valid { - m.Description = description.String - } - if sourceURL.Valid { - m.SourceURL = sourceURL.String - } - if documentationURL.Valid { - m.DocumentationURL = documentationURL.String - } - if licenses.Valid { - m.Licenses = licenses.String - } - if iconURL.Valid { - m.IconURL = iconURL.String - } - r.Manifests = append(r.Manifests, m) } manifestRows.Close() - // Aggregate repository-level annotations from most recent manifest - if len(r.Manifests) > 0 { - latest := r.Manifests[0] - r.Title = latest.Title - r.Description = latest.Description - r.SourceURL = latest.SourceURL - r.DocumentationURL = latest.DocumentationURL - r.Licenses = latest.Licenses - r.IconURL = latest.IconURL + // Fetch repository-level annotations from annotations table + annotations, err := GetRepositoryAnnotations(db, did, repository) + if err != nil { + return nil, err } + r.Title = annotations["org.opencontainers.image.title"] + r.Description = annotations["org.opencontainers.image.description"] + r.SourceURL = annotations["org.opencontainers.image.source"] + r.DocumentationURL = annotations["org.opencontainers.image.documentation"] + r.Licenses = annotations["org.opencontainers.image.licenses"] + r.IconURL = annotations["io.atcr.icon"] + r.ReadmeURL = annotations["io.atcr.readme"] + return &r, nil } @@ -1648,9 +1474,9 @@ func GetFeaturedRepositories(db *sql.DB, limit int) ([]FeaturedRepository, error m.did, u.handle, m.repository, - m.title, - m.description, - m.icon_url, + COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''), + COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''), + COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''), rs.pull_count, rs.star_count FROM latest_manifests lm @@ -1670,24 +1496,12 @@ func GetFeaturedRepositories(db *sql.DB, limit int) ([]FeaturedRepository, error var featured []FeaturedRepository for rows.Next() { var f FeaturedRepository - var title, description, iconURL sql.NullString if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository, - &title, &description, &iconURL, &f.PullCount, &f.StarCount); err != nil { + &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount); err != nil { return nil, err } - // Convert NullString to string - if title.Valid { - f.Title = title.String - } - if description.Valid { - f.Description = description.String - } - if iconURL.Valid { - f.IconURL = iconURL.String - } - featured = append(featured, f) } diff --git a/pkg/appview/db/queries_test.go b/pkg/appview/db/queries_test.go index 5071dd4..0e7a4b4 100644 --- a/pkg/appview/db/queries_test.go +++ b/pkg/appview/db/queries_test.go @@ -25,78 +25,100 @@ func TestGetRepositoryMetadata(t *testing.T) { t.Fatalf("Failed to insert user: %v", err) } - // Test 1: No manifests - should return empty strings - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent") + // Test 1: No manifests - should return empty map + metadata, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent") if err != nil { t.Fatalf("Expected no error for nonexistent repo, got: %v", err) } - if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" || readmeURL != "" { - t.Error("Expected all empty strings for nonexistent repository") + if len(metadata) != 0 { + t.Errorf("Expected empty map for nonexistent repository, got %d entries", len(metadata)) } - // Test 2: Insert manifest with metadata + // Test 2: Insert manifest and annotations _, err = db.Exec(` - INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at, - title, description, source_url, documentation_url, licenses, icon_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) `, testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", - time.Now().Add(-2*time.Hour), - "My App", "A cool application", "https://github.com/user/myapp", "https://docs.example.com", "MIT", "https://example.com/icon.png") + time.Now().Add(-2*time.Hour)) if err != nil { t.Fatalf("Failed to insert manifest: %v", err) } + // Insert annotations separately + err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", map[string]string{ + "org.opencontainers.image.title": "My App", + "org.opencontainers.image.description": "A cool application", + "org.opencontainers.image.source": "https://github.com/user/myapp", + "org.opencontainers.image.documentation": "https://docs.example.com", + "org.opencontainers.image.licenses": "MIT", + "io.atcr.icon": "https://example.com/icon.png", + }) + if err != nil { + t.Fatalf("Failed to insert annotations: %v", err) + } + // Test 3: Retrieve metadata - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") + metadata, err = GetRepositoryMetadata(db, testUser.DID, "myapp") if err != nil { t.Fatalf("Failed to get repository metadata: %v", err) } - if title != "My App" { - t.Errorf("Expected title 'My App', got '%s'", title) + if metadata["org.opencontainers.image.title"] != "My App" { + t.Errorf("Expected title 'My App', got '%s'", metadata["org.opencontainers.image.title"]) } - if description != "A cool application" { - t.Errorf("Expected description 'A cool application', got '%s'", description) + if metadata["org.opencontainers.image.description"] != "A cool application" { + t.Errorf("Expected description 'A cool application', got '%s'", metadata["org.opencontainers.image.description"]) } - if sourceURL != "https://github.com/user/myapp" { - t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", sourceURL) + if metadata["org.opencontainers.image.source"] != "https://github.com/user/myapp" { + t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", metadata["org.opencontainers.image.source"]) } - if documentationURL != "https://docs.example.com" { - t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", documentationURL) + if metadata["org.opencontainers.image.documentation"] != "https://docs.example.com" { + t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", metadata["org.opencontainers.image.documentation"]) } - if licenses != "MIT" { - t.Errorf("Expected licenses 'MIT', got '%s'", licenses) + if metadata["org.opencontainers.image.licenses"] != "MIT" { + t.Errorf("Expected licenses 'MIT', got '%s'", metadata["org.opencontainers.image.licenses"]) } - if iconURL != "https://example.com/icon.png" { - t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", iconURL) + if metadata["io.atcr.icon"] != "https://example.com/icon.png" { + t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", metadata["io.atcr.icon"]) } - // Test 4: Insert newer manifest with different metadata + // Test 4: Insert newer manifest with different annotations _, err = db.Exec(` - INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at, - title, description, source_url, documentation_url, licenses, icon_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) `, testUser.DID, "myapp", "sha256:def456", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", - time.Now(), // Most recent - "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") + time.Now()) // Most recent if err != nil { t.Fatalf("Failed to insert newer manifest: %v", err) } + // Update annotations with new values (simulates latest manifest having different annotations) + err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", map[string]string{ + "org.opencontainers.image.title": "My App v2", + "org.opencontainers.image.description": "An even cooler application", + "org.opencontainers.image.source": "https://github.com/user/myapp-v2", + "org.opencontainers.image.documentation": "https://v2.docs.example.com", + "org.opencontainers.image.licenses": "Apache-2.0", + "io.atcr.icon": "https://example.com/icon-v2.png", + }) + if err != nil { + t.Fatalf("Failed to update annotations: %v", err) + } + // Test 5: Should return metadata from most recent manifest - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") + metadata, err = GetRepositoryMetadata(db, testUser.DID, "myapp") if err != nil { t.Fatalf("Failed to get repository metadata: %v", err) } - if title != "My App v2" { - t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", title) + if metadata["org.opencontainers.image.title"] != "My App v2" { + t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", metadata["org.opencontainers.image.title"]) } - if description != "An even cooler application" { - t.Errorf("Expected description from newest manifest, got '%s'", description) + if metadata["org.opencontainers.image.description"] != "An even cooler application" { + t.Errorf("Expected description from newest manifest, got '%s'", metadata["org.opencontainers.image.description"]) } - if licenses != "Apache-2.0" { - t.Errorf("Expected licenses 'Apache-2.0', got '%s'", licenses) + if metadata["org.opencontainers.image.licenses"] != "Apache-2.0" { + t.Errorf("Expected licenses 'Apache-2.0', got '%s'", metadata["org.opencontainers.image.licenses"]) } // Test 6: Manifest with NULL metadata fields @@ -108,14 +130,14 @@ func TestGetRepositoryMetadata(t *testing.T) { t.Fatalf("Failed to insert minimal manifest: %v", err) } - // Test 7: Should handle NULL fields gracefully - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app") + // Test 7: Should handle missing annotations gracefully + metadata, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app") if err != nil { t.Fatalf("Failed to get repository metadata for minimal app: %v", err) } - if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" || readmeURL != "" { - t.Error("Expected all empty strings for manifest with NULL metadata fields") + if len(metadata) != 0 { + t.Error("Expected empty map for manifest with no annotations") } } @@ -139,24 +161,17 @@ func TestInsertManifest(t *testing.T) { t.Fatalf("Failed to insert user: %v", err) } - // Test 1: Insert new manifest with all fields populated + // Test 1: Insert new manifest with core fields manifest1 := &Manifest{ - DID: testUser.DID, - Repository: "myapp", - Digest: "sha256:abc123", - HoldEndpoint: "did:web:hold.example.com", - SchemaVersion: 2, - MediaType: "application/vnd.oci.image.manifest.v1+json", - ConfigDigest: "sha256:config123", - ConfigSize: 1024, - CreatedAt: time.Now(), - Title: "My App", - Description: "A cool application", - SourceURL: "https://github.com/user/myapp", - DocumentationURL: "https://docs.example.com", - Licenses: "MIT", - IconURL: "https://example.com/icon.png", - ReadmeURL: "https://github.com/user/myapp/blob/main/README.md", + DID: testUser.DID, + Repository: "myapp", + Digest: "sha256:abc123", + HoldEndpoint: "did:web:hold.example.com", + SchemaVersion: 2, + MediaType: "application/vnd.oci.image.manifest.v1+json", + ConfigDigest: "sha256:config123", + ConfigSize: 1024, + CreatedAt: time.Now(), } id1, err := InsertManifest(db, manifest1) @@ -167,6 +182,21 @@ func TestInsertManifest(t *testing.T) { t.Error("Expected non-zero manifest ID") } + // Insert annotations separately + annotations := map[string]string{ + "org.opencontainers.image.title": "My App", + "org.opencontainers.image.description": "A cool application", + "org.opencontainers.image.source": "https://github.com/user/myapp", + "org.opencontainers.image.documentation": "https://docs.example.com", + "org.opencontainers.image.licenses": "MIT", + "io.atcr.icon": "https://example.com/icon.png", + "io.atcr.readme": "https://github.com/user/myapp/blob/main/README.md", + } + err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", annotations) + if err != nil { + t.Fatalf("Failed to insert annotations: %v", err) + } + // Verify the manifest was inserted correctly retrieved, err := GetManifest(db, manifest1.Digest) if err != nil { @@ -175,11 +205,17 @@ func TestInsertManifest(t *testing.T) { if retrieved.ID != id1 { t.Errorf("Expected ID %d, got %d", id1, retrieved.ID) } - if retrieved.Title != "My App" { - t.Errorf("Expected title 'My App', got '%s'", retrieved.Title) + + // Verify annotations were inserted + retrievedAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "myapp") + if err != nil { + t.Fatalf("Failed to retrieve annotations: %v", err) } - if retrieved.ReadmeURL != "https://github.com/user/myapp/blob/main/README.md" { - t.Errorf("Expected readme_url, got '%s'", retrieved.ReadmeURL) + if retrievedAnnotations["org.opencontainers.image.title"] != "My App" { + t.Errorf("Expected title 'My App', got '%s'", retrievedAnnotations["org.opencontainers.image.title"]) + } + if retrievedAnnotations["io.atcr.readme"] != "https://github.com/user/myapp/blob/main/README.md" { + t.Errorf("Expected readme_url, got '%s'", retrievedAnnotations["io.atcr.readme"]) } // Test 2: Insert manifest with minimal fields (NULLs for annotations) @@ -201,32 +237,30 @@ func TestInsertManifest(t *testing.T) { t.Error("Expected non-zero manifest ID for minimal manifest") } - retrieved2, err := GetManifest(db, manifest2.Digest) + _, err = GetManifest(db, manifest2.Digest) if err != nil { t.Fatalf("Failed to retrieve minimal manifest: %v", err) } - if retrieved2.Title != "" { - t.Errorf("Expected empty title for minimal manifest, got '%s'", retrieved2.Title) + // Verify no annotations exist for minimal manifest + minimalAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "minimal") + if err != nil { + t.Fatalf("Failed to get minimal annotations: %v", err) + } + if len(minimalAnnotations) != 0 { + t.Errorf("Expected no annotations for minimal manifest, got %d", len(minimalAnnotations)) } // Test 3: Upsert existing manifest (same DID+repo+digest) - verify UPDATE path manifest1Updated := &Manifest{ - DID: testUser.DID, - Repository: "myapp", - Digest: "sha256:abc123", // Same digest - should trigger UPDATE - HoldEndpoint: "did:web:hold2.example.com", - SchemaVersion: 2, - MediaType: "application/vnd.oci.image.manifest.v1+json", - ConfigDigest: "sha256:newconfig", - ConfigSize: 2048, - CreatedAt: time.Now(), - Title: "My App v2", - Description: "An updated application", - SourceURL: "https://github.com/user/myapp-v2", - DocumentationURL: "https://v2.docs.example.com", - Licenses: "Apache-2.0", - IconURL: "https://example.com/icon-v2.png", - ReadmeURL: "https://github.com/user/myapp/blob/v2/README.md", + DID: testUser.DID, + Repository: "myapp", + Digest: "sha256:abc123", // Same digest - should trigger UPDATE + HoldEndpoint: "did:web:hold2.example.com", + SchemaVersion: 2, + MediaType: "application/vnd.oci.image.manifest.v1+json", + ConfigDigest: "sha256:newconfig", + ConfigSize: 2048, + CreatedAt: time.Now(), } id3, err := InsertManifest(db, manifest1Updated) @@ -238,19 +272,40 @@ func TestInsertManifest(t *testing.T) { t.Errorf("Expected upsert to return same ID %d, got %d", id1, id3) } + // Update annotations separately + updatedAnnotations := map[string]string{ + "org.opencontainers.image.title": "My App v2", + "org.opencontainers.image.description": "An updated application", + "org.opencontainers.image.source": "https://github.com/user/myapp-v2", + "org.opencontainers.image.documentation": "https://v2.docs.example.com", + "org.opencontainers.image.licenses": "Apache-2.0", + "io.atcr.icon": "https://example.com/icon-v2.png", + "io.atcr.readme": "https://github.com/user/myapp/blob/v2/README.md", + } + err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", updatedAnnotations) + if err != nil { + t.Fatalf("Failed to update annotations: %v", err) + } + // Verify the manifest was updated retrievedUpdated, err := GetManifest(db, manifest1.Digest) if err != nil { t.Fatalf("Failed to retrieve updated manifest: %v", err) } - if retrievedUpdated.Title != "My App v2" { - t.Errorf("Expected updated title 'My App v2', got '%s'", retrievedUpdated.Title) - } if retrievedUpdated.HoldEndpoint != "did:web:hold2.example.com" { t.Errorf("Expected updated hold_endpoint, got '%s'", retrievedUpdated.HoldEndpoint) } - if retrievedUpdated.ReadmeURL != "https://github.com/user/myapp/blob/v2/README.md" { - t.Errorf("Expected updated readme_url, got '%s'", retrievedUpdated.ReadmeURL) + + // Verify annotations were updated + retrievedUpdatedAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "myapp") + if err != nil { + t.Fatalf("Failed to retrieve updated annotations: %v", err) + } + if retrievedUpdatedAnnotations["org.opencontainers.image.title"] != "My App v2" { + t.Errorf("Expected updated title 'My App v2', got '%s'", retrievedUpdatedAnnotations["org.opencontainers.image.title"]) + } + if retrievedUpdatedAnnotations["io.atcr.readme"] != "https://github.com/user/myapp/blob/v2/README.md" { + t.Errorf("Expected updated readme_url, got '%s'", retrievedUpdatedAnnotations["io.atcr.readme"]) } // Test 4: Verify count - should have 2 manifests (not 3, because one was upserted) @@ -404,7 +459,6 @@ func TestManifestOperations(t *testing.T) { SchemaVersion: 2, MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(), - Title: "App 1", }, { DID: testUser.DID, @@ -414,7 +468,6 @@ func TestManifestOperations(t *testing.T) { SchemaVersion: 2, MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(), - Title: "App 1 v2", }, { DID: testUser.DID, @@ -424,7 +477,6 @@ func TestManifestOperations(t *testing.T) { SchemaVersion: 2, MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(), - Title: "App 2", }, } @@ -435,13 +487,27 @@ func TestManifestOperations(t *testing.T) { } } + // Insert annotations for test manifests + err = UpsertRepositoryAnnotations(db, testUser.DID, "app1", map[string]string{ + "org.opencontainers.image.title": "App 1", + }) + if err != nil { + t.Fatalf("Failed to insert app1 annotations: %v", err) + } + err = UpsertRepositoryAnnotations(db, testUser.DID, "app2", map[string]string{ + "org.opencontainers.image.title": "App 2", + }) + if err != nil { + t.Fatalf("Failed to insert app2 annotations: %v", err) + } + // Test 1: GetManifest - found retrieved, err := GetManifest(db, "sha256:aaa") if err != nil { t.Fatalf("Failed to get manifest: %v", err) } - if retrieved.Title != "App 1" { - t.Errorf("Expected title 'App 1', got '%s'", retrieved.Title) + if retrieved.Digest != "sha256:aaa" { + t.Errorf("Expected digest 'sha256:aaa', got '%s'", retrieved.Digest) } // Test 2: GetManifest - not found diff --git a/pkg/appview/db/schema.sql b/pkg/appview/db/schema.sql index bd009c7..bb40636 100644 --- a/pkg/appview/db/schema.sql +++ b/pkg/appview/db/schema.sql @@ -28,13 +28,6 @@ CREATE TABLE IF NOT EXISTS manifests ( config_digest TEXT, config_size INTEGER, created_at TIMESTAMP NOT NULL, - title TEXT, - description TEXT, - source_url TEXT, - documentation_url TEXT, - licenses TEXT, - icon_url TEXT, - readme_url TEXT, UNIQUE(did, repository, digest), FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE ); @@ -42,6 +35,18 @@ CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); +CREATE TABLE IF NOT EXISTS repository_annotations ( + did TEXT NOT NULL, + repository TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(did, repository, key), + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_repository_annotations_did_repo ON repository_annotations(did, repository); +CREATE INDEX IF NOT EXISTS idx_repository_annotations_key ON repository_annotations(key); + CREATE TABLE IF NOT EXISTS layers ( manifest_id INTEGER NOT NULL, digest TEXT NOT NULL, diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index a9b6bb4..c363058 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -136,19 +136,20 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request ManifestCount: len(manifests), } - // Fetch repository metadata from most recent manifest - title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository) + // Fetch repository metadata from annotations table + metadata, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository) if err != nil { log.Printf("Failed to fetch repository metadata: %v", err) // Continue without metadata on error } else { - repo.Title = title - repo.Description = description - repo.SourceURL = sourceURL - repo.DocumentationURL = documentationURL - repo.Licenses = licenses - repo.IconURL = iconURL - repo.ReadmeURL = readmeURL + repo.Title = metadata["org.opencontainers.image.title"] + repo.Description = metadata["org.opencontainers.image.description"] + repo.SourceURL = metadata["org.opencontainers.image.source"] + repo.DocumentationURL = metadata["org.opencontainers.image.documentation"] + repo.Licenses = metadata["org.opencontainers.image.licenses"] + repo.IconURL = metadata["io.atcr.icon"] + repo.ReadmeURL = metadata["io.atcr.readme"] + repo.Version = metadata["org.opencontainers.image.version"] } // Fetch star count diff --git a/pkg/appview/jetstream/processor.go b/pkg/appview/jetstream/processor.go index aae5b9f..f20cb0a 100644 --- a/pkg/appview/jetstream/processor.go +++ b/pkg/appview/jetstream/processor.go @@ -143,37 +143,19 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData if err := json.Unmarshal(recordData, &manifestRecord); err != nil { return 0, fmt.Errorf("failed to unmarshal manifest: %w", err) } - // Extract OCI annotations from manifest - var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string - if manifestRecord.Annotations != nil { - title = manifestRecord.Annotations["org.opencontainers.image.title"] - description = manifestRecord.Annotations["org.opencontainers.image.description"] - sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"] - documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"] - licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"] - iconURL = manifestRecord.Annotations["io.atcr.icon"] - readmeURL = manifestRecord.Annotations["io.atcr.readme"] - } - // Detect manifest type isManifestList := len(manifestRecord.Manifests) > 0 - // Prepare manifest for insertion + // Prepare manifest for insertion (WITHOUT annotation fields) manifest := &db.Manifest{ - DID: did, - Repository: manifestRecord.Repository, - Digest: manifestRecord.Digest, - MediaType: manifestRecord.MediaType, - SchemaVersion: manifestRecord.SchemaVersion, - HoldEndpoint: manifestRecord.HoldEndpoint, - CreatedAt: manifestRecord.CreatedAt, - Title: title, - Description: description, - SourceURL: sourceURL, - DocumentationURL: documentationURL, - Licenses: licenses, - IconURL: iconURL, - ReadmeURL: readmeURL, + DID: did, + Repository: manifestRecord.Repository, + Digest: manifestRecord.Digest, + MediaType: manifestRecord.MediaType, + SchemaVersion: manifestRecord.SchemaVersion, + HoldEndpoint: manifestRecord.HoldEndpoint, + CreatedAt: manifestRecord.CreatedAt, + // Annotations removed - stored separately in repository_annotations table } // Set config fields only for image manifests (not manifest lists) @@ -202,6 +184,25 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData } } + // Update repository annotations ONLY if manifest has at least one non-empty annotation + if manifestRecord.Annotations != nil { + hasData := false + for _, value := range manifestRecord.Annotations { + if value != "" { + hasData = true + break + } + } + + if hasData { + // Replace all annotations for this repository + err = db.UpsertRepositoryAnnotations(p.db, did, manifestRecord.Repository, manifestRecord.Annotations) + if err != nil { + return 0, fmt.Errorf("failed to upsert annotations: %w", err) + } + } + } + // Insert manifest references or layers if isManifestList { // Insert manifest references (for manifest lists/indexes) diff --git a/pkg/appview/static/css/style.css b/pkg/appview/static/css/style.css index e595f9a..2c02116 100644 --- a/pkg/appview/static/css/style.css +++ b/pkg/appview/static/css/style.css @@ -545,6 +545,12 @@ a.license-badge:hover { box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } +.version-badge { + background: #f3e5f5; + color: #7b1fa2; + border: 1px solid #ba68c8; +} + .repo-description { color: var(--border-dark); font-size: 0.95rem; diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index 5f102c4..d838fce 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -43,8 +43,13 @@ - {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }} + {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }}