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 }}
+ {{ if .Repository.Version }} + + {{ end }} {{ if .Repository.Licenses }} {{ range parseLicenses .Repository.Licenses }} {{ if .IsValid }} diff --git a/pkg/hold/config.go b/pkg/hold/config.go index 428c7db..ccf7306 100644 --- a/pkg/hold/config.go +++ b/pkg/hold/config.go @@ -28,6 +28,10 @@ type RegistrationConfig struct { // If true, creates/maintains a crew record with memberPattern: "*" (allows all authenticated users) // If false, deletes the wildcard crew record if it exists AllowAllCrew bool `yaml:"allow_all_crew"` + + // ProfileAvatarURL is the URL to download the avatar image from (from env: HOLD_PROFILE_AVATAR) + // If set, the avatar will be downloaded and uploaded as a blob during bootstrap + ProfileAvatarURL string `yaml:"profile_avatar_url"` } // StorageConfig wraps distribution's storage configuration @@ -91,6 +95,7 @@ func LoadConfigFromEnv() (*Config, error) { // Registration configuration (optional) cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER") cfg.Registration.AllowAllCrew = os.Getenv("HOLD_ALLOW_ALL_CREW") == "true" + cfg.Registration.ProfileAvatarURL = getEnvOrDefault("HOLD_PROFILE_AVATAR", "https://imgs.blue/evan.jarrett.net/1TpTOdtS60GdJWBYEqtK22y688jajbQ9a5kbYRFtwuqrkBAE") // Database configuration (optional - enables embedded PDS) // Note: HOLD_DATABASE_DIR is a directory path, carstore creates db.sqlite3 inside it diff --git a/pkg/hold/oci/xrpc_test.go b/pkg/hold/oci/xrpc_test.go index 5c320bb..8b8dc6c 100644 --- a/pkg/hold/oci/xrpc_test.go +++ b/pkg/hold/oci/xrpc_test.go @@ -66,7 +66,7 @@ func setupTestOCIHandler(t *testing.T) (*XRPCHandler, context.Context) { // Bootstrap PDS ownerDID := "did:plc:owner123" - if err := holdPDS.Bootstrap(ctx, ownerDID, true, false); err != nil { + if err := holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } diff --git a/pkg/hold/pds/auth_test.go b/pkg/hold/pds/auth_test.go index 1eb05e8..08ece18 100644 --- a/pkg/hold/pds/auth_test.go +++ b/pkg/hold/pds/auth_test.go @@ -512,7 +512,7 @@ func TestValidateBlobWriteAccess_ServiceToken_Owner(t *testing.T) { holdDID := "did:web:hold01.atcr.io" // Bootstrap with owner - err := pds.Bootstrap(ctx, ownerDID, true, false) + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } @@ -546,7 +546,7 @@ func TestValidateBlobWriteAccess_ServiceToken_CrewWithPermission(t *testing.T) { holdDID := "did:web:hold01.atcr.io" // Bootstrap - err := pds.Bootstrap(ctx, ownerDID, true, false) + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } @@ -605,7 +605,7 @@ func TestValidateBlobWriteAccess_ServiceToken_CrewWithoutPermission(t *testing.T holdDID := "did:web:hold01.atcr.io" // Bootstrap - err := pds.Bootstrap(ctx, ownerDID, true, false) + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } @@ -650,7 +650,7 @@ func TestValidateBlobWriteAccess_Owner(t *testing.T) { ownerDID := "did:plc:owner123" // Bootstrap with owner - err := pds.Bootstrap(ctx, ownerDID, true, false) + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } @@ -696,7 +696,7 @@ func TestValidateBlobWriteAccess_CrewPermissions(t *testing.T) { ownerDID := "did:plc:owner123" // Bootstrap - err := pds.Bootstrap(ctx, ownerDID, true, false) + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } @@ -769,7 +769,7 @@ func TestValidateBlobReadAccess_PublicHold(t *testing.T) { ownerDID := "did:plc:owner123" // Bootstrap with public=true - err := pds.Bootstrap(ctx, ownerDID, true, false) + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } @@ -806,7 +806,7 @@ func TestValidateBlobReadAccess_PrivateHold(t *testing.T) { ownerDID := "did:plc:owner123" // Bootstrap with public=false - err := pds.Bootstrap(ctx, ownerDID, false, false) + err := pds.Bootstrap(ctx, nil, ownerDID, false, false, "") if err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } @@ -848,7 +848,7 @@ func TestValidateOwnerOrCrewAdmin(t *testing.T) { ownerDID := "did:plc:owner123" // Bootstrap - err := pds.Bootstrap(ctx, ownerDID, true, false) + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } @@ -916,7 +916,7 @@ func TestCrewPermissions(t *testing.T) { ownerDID := "did:plc:owner123" // Bootstrap - err := pds.Bootstrap(ctx, ownerDID, true, false) + err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } @@ -1040,7 +1040,7 @@ func TestCaptainRecordSettings(t *testing.T) { ownerDID := "did:plc:owner123" // Bootstrap with specified settings - err := pds.Bootstrap(ctx, ownerDID, tt.public, tt.allowAllCrew) + err := pds.Bootstrap(ctx, nil, ownerDID, tt.public, tt.allowAllCrew, "") if err != nil { t.Fatalf("Failed to bootstrap PDS: %v", err) } diff --git a/pkg/hold/pds/profile.go b/pkg/hold/pds/profile.go new file mode 100644 index 0000000..16411a6 --- /dev/null +++ b/pkg/hold/pds/profile.go @@ -0,0 +1,174 @@ +package pds + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "io" + "net/http" + "time" + + bsky "github.com/bluesky-social/indigo/api/bsky" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/distribution/distribution/v3/registry/storage/driver" + "github.com/ipfs/go-cid" + "github.com/multiformats/go-multihash" +) + +const ( + // ProfileRkey is the fixed rkey for the profile record (singleton) + ProfileRkey = "self" + + // ProfileCollection is the collection name for Bluesky actor profiles + ProfileCollection = "app.bsky.actor.profile" +) + +// downloadImage downloads an image from a URL and returns the data and content type +func downloadImage(ctx context.Context, url string) ([]byte, string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, "", fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("failed to download image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + // Read image data + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("failed to read image data: %w", err) + } + + // Get content type from response header + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + return data, contentType, nil +} + +// uploadBlobToStorage uploads a blob to the hold's storage and returns a blob reference +// This stores the blob at the ATProto path for the hold's DID +func uploadBlobToStorage(ctx context.Context, storageDriver driver.StorageDriver, did string, data []byte, mimeType string) (*lexutil.LexBlob, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty blob data") + } + + size := int64(len(data)) + + // Compute SHA-256 hash + hash := sha256.Sum256(data) + + // Create CIDv1 with SHA-256 multihash + mh, err := multihash.EncodeName(hash[:], "sha2-256") + if err != nil { + return nil, fmt.Errorf("failed to encode multihash: %w", err) + } + + // Create CIDv1 with raw codec (0x55) + // ATProto uses CIDv1 with raw codec for blobs + blobCID := cid.NewCidV1(0x55, mh) + + // Store blob via distribution driver at ATProto path + path := atprotoBlobPath(did, blobCID.String()) + + // Write blob to storage using distribution driver + writer, err := storageDriver.Writer(ctx, path, false) + if err != nil { + return nil, fmt.Errorf("failed to create writer: %w", err) + } + + // Write data + n, err := io.Copy(writer, bytes.NewReader(data)) + if err != nil { + writer.Cancel(ctx) + return nil, fmt.Errorf("failed to write blob: %w", err) + } + + // Commit the write + if err := writer.Commit(ctx); err != nil { + return nil, fmt.Errorf("failed to commit blob: %w", err) + } + + if n != size { + return nil, fmt.Errorf("size mismatch: wrote %d bytes, expected %d", n, size) + } + + // Create blob reference in the format expected by bsky.ActorProfile + // LexLink is a type alias for cid.Cid + lexLink := lexutil.LexLink(blobCID) + blob := &lexutil.LexBlob{ + Ref: lexLink, + MimeType: mimeType, + Size: size, + } + + return blob, nil +} + +// CreateProfileRecord creates the app.bsky.actor.profile record for the hold +// This will FAIL if the profile record already exists. +func (p *HoldPDS) CreateProfileRecord(ctx context.Context, storageDriver driver.StorageDriver, displayName, description, avatarURL string) (cid.Cid, error) { + // Create profile struct + profile := &bsky.ActorProfile{ + DisplayName: &displayName, + Description: &description, + } + + // Download and upload avatar if URL is provided + if avatarURL != "" { + fmt.Printf("Downloading avatar from %s\n", avatarURL) + imageData, mimeType, err := downloadImage(ctx, avatarURL) + if err != nil { + return cid.Undef, fmt.Errorf("failed to download avatar: %w", err) + } + + fmt.Printf("Uploading avatar blob (%d bytes, %s)\n", len(imageData), mimeType) + avatarBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, imageData, mimeType) + if err != nil { + return cid.Undef, fmt.Errorf("failed to upload avatar blob: %w", err) + } + + profile.Avatar = avatarBlob + fmt.Printf("Avatar uploaded successfully: %s\n", avatarBlob.Ref.String()) + } + + // Use repomgr.PutRecord - creates with explicit rkey, fails if already exists + recordPath, recordCID, err := p.repomgr.PutRecord(ctx, p.uid, ProfileCollection, ProfileRkey, profile) + if err != nil { + return cid.Undef, fmt.Errorf("failed to create profile record: %w", err) + } + + fmt.Printf("Created profile record at %s, cid: %s\n", recordPath, recordCID) + return recordCID, nil +} + +// GetProfileRecord retrieves the app.bsky.actor.profile record +func (p *HoldPDS) GetProfileRecord(ctx context.Context) (cid.Cid, *bsky.ActorProfile, error) { + // Use repomgr.GetRecord + recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, ProfileCollection, ProfileRkey, cid.Undef) + if err != nil { + return cid.Undef, nil, fmt.Errorf("failed to get profile record: %w", err) + } + + // Type assert to bsky.ActorProfile + profileRecord, ok := val.(*bsky.ActorProfile) + if !ok { + return cid.Undef, nil, fmt.Errorf("unexpected type for profile record: %T", val) + } + + return recordCID, profileRecord, nil +} diff --git a/pkg/hold/pds/server.go b/pkg/hold/pds/server.go index dca8f63..a380fff 100644 --- a/pkg/hold/pds/server.go +++ b/pkg/hold/pds/server.go @@ -13,6 +13,7 @@ import ( lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/bluesky-social/indigo/models" "github.com/bluesky-social/indigo/repo" + "github.com/distribution/distribution/v3/registry/storage/driver" "github.com/ipfs/go-cid" ) @@ -107,21 +108,24 @@ func (p *HoldPDS) RepomgrRef() *RepoManager { return p.repomgr } -// Bootstrap initializes the hold with the captain record and owner as first crew member -func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string, public bool, allowAllCrew bool) error { +// Bootstrap initializes the hold with the captain record, owner as first crew member, and profile +func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL string) error { if ownerDID == "" { return nil } // Check if captain record already exists (idempotent bootstrap) _, _, err := p.GetCaptainRecord(ctx) - if err == nil { - // Captain record exists, we're good - fmt.Printf("✅ Captain record exists, skipping bootstrap\n") - return nil + captainExists := (err == nil) + + if captainExists { + // Captain record exists, skip captain/crew setup but still create profile if needed + fmt.Printf("✅ Captain record exists, skipping captain/crew setup\n") + } else { + fmt.Printf("🚀 Bootstrapping hold PDS with owner: %s\n", ownerDID) } - fmt.Printf("🚀 Bootstrapping hold PDS with owner: %s\n", ownerDID) + if !captainExists { // Initialize repo if it doesn't exist yet // Check if repo exists by trying to get the head @@ -150,7 +154,29 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string, public bool, a return fmt.Errorf("failed to add owner as crew member: %w", err) } - fmt.Printf("✅ Added %s as hold admin\n", ownerDID) + fmt.Printf("✅ Added %s as hold admin\n", ownerDID) + } + + // Create profile record (idempotent - check if exists first) + // This runs even if captain exists (for existing holds being upgraded) + // Skip if no storage driver (e.g., in tests) + if storageDriver != nil { + _, _, err = p.GetProfileRecord(ctx) + if err != nil { + // Profile doesn't exist, create it + displayName := "Cargo Hold" + description := "ahoy from the cargo hold" + + _, err = p.CreateProfileRecord(ctx, storageDriver, displayName, description, avatarURL) + if err != nil { + return fmt.Errorf("failed to create profile record: %w", err) + } + fmt.Printf("✅ Created profile record (displayName=%s)\n", displayName) + } else { + fmt.Printf("✅ Profile record already exists, skipping\n") + } + } + return nil } diff --git a/pkg/hold/pds/server_test.go b/pkg/hold/pds/server_test.go index debbbae..6f3a847 100644 --- a/pkg/hold/pds/server_test.go +++ b/pkg/hold/pds/server_test.go @@ -69,7 +69,7 @@ func TestNewHoldPDS_ExistingRepo(t *testing.T) { // Bootstrap with a captain record ownerDID := "did:plc:owner123" - if err := pds1.Bootstrap(ctx, ownerDID, true, false); err != nil { + if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -129,7 +129,7 @@ func TestBootstrap_NewRepo(t *testing.T) { publicAccess := true allowAllCrew := false - err = pds.Bootstrap(ctx, ownerDID, publicAccess, allowAllCrew) + err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "") if err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -204,7 +204,7 @@ func TestBootstrap_Idempotent(t *testing.T) { ownerDID := "did:plc:alice123" // First bootstrap - err = pds.Bootstrap(ctx, ownerDID, true, false) + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("First bootstrap failed: %v", err) } @@ -223,7 +223,7 @@ func TestBootstrap_Idempotent(t *testing.T) { crewCount1 := len(crew1) // Second bootstrap (should be idempotent - skip creation) - err = pds.Bootstrap(ctx, ownerDID, true, false) + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Second bootstrap failed: %v", err) } @@ -268,7 +268,7 @@ func TestBootstrap_EmptyOwner(t *testing.T) { defer pds.Close() // Bootstrap with empty owner DID (should be no-op) - err = pds.Bootstrap(ctx, "", true, false) + err = pds.Bootstrap(ctx, nil, "", true, false, "") if err != nil { t.Fatalf("Bootstrap with empty owner should not error: %v", err) } @@ -302,7 +302,7 @@ func TestLexiconTypeRegistration(t *testing.T) { // Bootstrap to create captain record ownerDID := "did:plc:alice123" - if err := pds.Bootstrap(ctx, ownerDID, true, false); err != nil { + if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -355,7 +355,7 @@ func TestBootstrap_DidWebOwner(t *testing.T) { publicAccess := true allowAllCrew := false - err = pds.Bootstrap(ctx, ownerDID, publicAccess, allowAllCrew) + err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "") if err != nil { t.Fatalf("Bootstrap failed with did:web owner: %v", err) } @@ -414,7 +414,7 @@ func TestBootstrap_MixedDIDs(t *testing.T) { // Bootstrap with did:plc owner plcOwner := "did:plc:alice123" - err = pds.Bootstrap(ctx, plcOwner, true, false) + err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "") if err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -509,7 +509,7 @@ func TestBootstrap_CrewWithoutCaptain(t *testing.T) { } // Bootstrap should create captain record - err = pds.Bootstrap(ctx, ownerDID, true, false) + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Bootstrap failed: %v", err) } @@ -585,7 +585,7 @@ func TestBootstrap_CaptainWithoutCrew(t *testing.T) { // Bootstrap should be idempotent but notice missing crew // Currently Bootstrap skips if captain exists, so crew won't be added - err = pds.Bootstrap(ctx, ownerDID, true, false) + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") if err != nil { t.Fatalf("Bootstrap failed: %v", err) } diff --git a/pkg/hold/pds/xrpc.go b/pkg/hold/pds/xrpc.go index 2f7ab82..b90c1bd 100644 --- a/pkg/hold/pds/xrpc.go +++ b/pkg/hold/pds/xrpc.go @@ -795,78 +795,66 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } -// HandleGetBlob wraps existing presigned download URL logic -// Supports both ATProto CIDs and OCI sha256 digests -// Authorization: If captain.public = true, open to all. If false, requires crew with blob:read permission. +// HandleGetBlob routes blob requests to appropriate handlers based on blob type +// Routes to: +// - handleGetOCIBlob for OCI image blobs (sha256:...) +// - handleGetATProtoBlob for ATProto blobs (CID format) func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) { - log.Printf("[HandleGetBlob] %s request received", r.Method) - did := r.URL.Query().Get("did") cidOrDigest := r.URL.Query().Get("cid") - log.Printf("[HandleGetBlob] did=%s, cid=%s", did, cidOrDigest) + log.Printf("[HandleGetBlob] %s request - did=%s, cid=%s", r.Method, did, cidOrDigest) if did == "" || cidOrDigest == "" { http.Error(w, "missing required parameters", http.StatusBadRequest) return } - // For OCI blobs (sha256:...), skip DID validation since they're content-addressed and globally deduplicated - // For ATProto blobs (CID format), validate DID since they're stored per-DID - if !strings.HasPrefix(cidOrDigest, "sha256:") { - // ATProto blob - validate DID - if did != h.pds.DID() { - log.Printf("[HandleGetBlob] DID mismatch for ATProto blob: got %s, expected %s", did, h.pds.DID()) - http.Error(w, "invalid did", http.StatusBadRequest) - return - } - } else { - // OCI blob - DID doesn't matter, use empty string for content-addressed storage - did = "" + // Route based on blob type + if strings.HasPrefix(cidOrDigest, "sha256:") { + // OCI blob (container image layers) + h.handleGetOCIBlob(w, r, did, cidOrDigest) + return } - // Validate blob read access + // ATProto blob (profile avatars, etc.) + h.handleGetATProtoBlob(w, r, did, cidOrDigest) +} + +// handleGetOCIBlob handles OCI container image blob requests +// Returns JSON with presigned URL for AppView integration +// Authorization: Protected by hold access control (captain.public or crew with blob:read) +func (h *XRPCHandler) handleGetOCIBlob(w http.ResponseWriter, r *http.Request, did, digest string) { + log.Printf("[handleGetOCIBlob] Processing OCI blob: %s", digest) + + // Validate blob read access (hold access control) // If captain.public = true, returns nil (public access allowed) // If captain.public = false, validates auth and checks for blob:read permission _, err := ValidateBlobReadAccess(r, h.pds, h.httpClient) if err != nil { - log.Printf("[HandleGetBlob] Authorization failed: %v", err) + log.Printf("[handleGetOCIBlob] Authorization failed: %v", err) http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden) return } - // Flexible digest parsing: accept both CID and sha256 digest formats - var digest string - if strings.HasPrefix(cidOrDigest, "sha256:") { - // OCI digest format - use directly - digest = cidOrDigest - } else { - // Standard ATProto CID - for ATCR OCI use case, we expect sha256 digests - // If a real CID is provided, we could convert it here, but for now - // we'll just pass it through and let the blob store handle it - digest = cidOrDigest - } - - // Determine presigned URL operation - // Check for ?method=HEAD query parameter first (from AppView), then fall back to request method - // HEAD and GET need different presigned URL signatures + // Determine presigned URL operation (GET or HEAD) + // Check for ?method=HEAD query parameter first (from AppView) operation := r.URL.Query().Get("method") if operation == "" { operation = "GET" } - // Generate presigned URL for the operation - presignedURL, err := h.GetPresignedURL(r.Context(), operation, digest, did) + // Generate presigned URL (use empty DID for content-addressed storage) + presignedURL, err := h.GetPresignedURL(r.Context(), operation, digest, "") if err != nil { - log.Printf("[HandleGetBlob] Failed to get presigned %s URL: digest=%s, did=%s, err=%v", operation, digest, did, err) + log.Printf("[handleGetOCIBlob] Failed to get presigned %s URL: %v", operation, err) http.Error(w, "failed to get presigned URL", http.StatusInternalServerError) return } - log.Printf("[HandleGetBlob] Returning presigned %s URL: %s", operation, presignedURL) + log.Printf("[handleGetOCIBlob] Returning presigned %s URL: %s", operation, presignedURL) - // Return JSON response with the presigned URL - // AppView will either redirect (GET) or proxy (HEAD) using this URL + // Return JSON response with presigned URL (AppView expects this format) response := map[string]string{ "url": presignedURL, } @@ -874,6 +862,37 @@ func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// handleGetATProtoBlob handles standard ATProto blob requests +// Returns 307 redirect to presigned URL (standard ATProto behavior) +// Authorization: Public per ATProto spec (no auth required) +func (h *XRPCHandler) handleGetATProtoBlob(w http.ResponseWriter, r *http.Request, did, cid string) { + log.Printf("[handleGetATProtoBlob] Processing ATProto blob: %s", cid) + + // Validate DID (ATProto blobs are stored per-DID for data sovereignty) + if did != h.pds.DID() { + log.Printf("[handleGetATProtoBlob] DID mismatch: got %s, expected %s", did, h.pds.DID()) + http.Error(w, "invalid did", http.StatusBadRequest) + return + } + + // Determine presigned URL operation (GET or HEAD) + operation := r.URL.Query().Get("method") + if operation == "" { + operation = "GET" + } + + // Generate presigned URL (use DID for per-DID storage path) + presignedURL, err := h.GetPresignedURL(r.Context(), operation, cid, did) + if err != nil { + log.Printf("[handleGetATProtoBlob] Failed to get presigned %s URL: %v", operation, err) + http.Error(w, "failed to get presigned URL", http.StatusInternalServerError) + return + } + + // Return 307 redirect (standard ATProto behavior - client fetches blob directly) + http.Redirect(w, r, presignedURL, http.StatusTemporaryRedirect) +} + // HandleListRepos lists all repositories in this PDS func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) { // Single-user PDS: return just this hold's repo diff --git a/pkg/hold/pds/xrpc_test.go b/pkg/hold/pds/xrpc_test.go index 7df61a4..c6ab6c0 100644 --- a/pkg/hold/pds/xrpc_test.go +++ b/pkg/hold/pds/xrpc_test.go @@ -46,7 +46,7 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) { r, w, _ := os.Pipe() os.Stdout = w - err = pds.Bootstrap(ctx, ownerDID, true, false) + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") // Restore stdout w.Close() @@ -1347,7 +1347,7 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockS3Service, r, w, _ := os.Pipe() os.Stdout = w - err = pds.Bootstrap(ctx, ownerDID, true, false) + err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "") // Restore stdout w.Close() @@ -1501,7 +1501,8 @@ func TestHandleUploadBlob_BlobStoreError(t *testing.T) { // Tests for HandleGetBlob -// TestHandleGetBlob tests com.atproto.sync.getBlob +// TestHandleGetBlob tests com.atproto.sync.getBlob with ATProto CID +// ATProto blobs should return 307 redirect to presigned URL // Spec: https://docs.bsky.app/docs/api/com-atproto-sync-get-blob func TestHandleGetBlob(t *testing.T) { handler, _, _ := setupTestXRPCHandlerWithBlobs(t) @@ -1517,26 +1518,20 @@ func TestHandleGetBlob(t *testing.T) { handler.HandleGetBlob(w, req) - // Should return 200 OK with JSON response containing presigned URL - if w.Code != http.StatusOK { - t.Errorf("Expected status 200 OK, got %d", w.Code) + // ATProto blob should return 307 Temporary Redirect + if w.Code != http.StatusTemporaryRedirect { + t.Errorf("Expected status 307 Temporary Redirect, got %d", w.Code) } - // Verify Content-Type is JSON - contentType := w.Header().Get("Content-Type") - if contentType != "application/json" { - t.Errorf("Expected Content-Type application/json, got %s", contentType) + // Verify Location header exists with presigned URL + location := w.Header().Get("Location") + if location == "" { + t.Error("Expected Location header in 307 redirect") } - // Parse JSON response - var response map[string]string - if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { - t.Fatalf("Failed to parse JSON response: %v", err) - } - - // Verify URL field exists (will be XRPC proxy URL since we don't have S3 client) - if response["url"] == "" { - t.Error("Expected url field in response") + // Should be XRPC proxy URL since we don't have S3 client + if !strings.Contains(location, "/xrpc/com.atproto.sync.getBlob") { + t.Errorf("Expected XRPC proxy URL, got: %s", location) } } @@ -1589,26 +1584,20 @@ func TestHandleGetBlob_HeadMethod(t *testing.T) { handler.HandleGetBlob(w, req) - // Should return 200 OK with JSON response containing presigned HEAD URL - if w.Code != http.StatusOK { - t.Errorf("Expected status 200 OK, got %d", w.Code) + // ATProto blob should return 307 Temporary Redirect (even for HEAD) + if w.Code != http.StatusTemporaryRedirect { + t.Errorf("Expected status 307 Temporary Redirect, got %d", w.Code) } - // Verify Content-Type is JSON - contentType := w.Header().Get("Content-Type") - if contentType != "application/json" { - t.Errorf("Expected Content-Type application/json, got %s", contentType) + // Verify Location header exists with presigned URL + location := w.Header().Get("Location") + if location == "" { + t.Error("Expected Location header in 307 redirect") } - // Parse JSON response - var response map[string]string - if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { - t.Fatalf("Failed to parse JSON response: %v", err) - } - - // Verify URL field exists (will be XRPC proxy URL since we don't have S3 client) - if response["url"] == "" { - t.Error("Expected url field in response") + // Should be XRPC proxy URL since we don't have S3 client + if !strings.Contains(location, "/xrpc/com.atproto.sync.getBlob") { + t.Errorf("Expected XRPC proxy URL, got: %s", location) } } @@ -1800,7 +1789,7 @@ func TestRequireOwnerOrCrewAdmin_Authorized(t *testing.T) { // Clean up - recreate captain record if it was deleted if w.Code == http.StatusOK { - handler.pds.Bootstrap(ctx, "did:plc:testowner123", true, false) + handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "") } } diff --git a/scripts/test-hold-endpoints.sh b/scripts/test-hold-endpoints.sh new file mode 100755 index 0000000..8ba76bf --- /dev/null +++ b/scripts/test-hold-endpoints.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +# Test script for ATProto sync endpoints on hold service +# Usage: ./test-hold-endpoints.sh [HOLD_URL] [DID] + +HOLD_URL="${1:-http://172.28.0.3:8080}" +# You'll need to replace this with your hold's actual DID +DID="${2:-did:web:172.28.0.3%3A8080}" + +echo "Testing ATProto sync endpoints on: $HOLD_URL" +echo "Using DID: $DID" +echo "==========================================" +echo "" + +# Test 1: List repositories +echo "1. Testing com.atproto.sync.listRepos" +echo " GET $HOLD_URL/xrpc/com.atproto.sync.listRepos" +echo " Response:" +curl -s -w "\n HTTP Status: %{http_code}\n" \ + "$HOLD_URL/xrpc/com.atproto.sync.listRepos" | jq . 2>/dev/null || cat +echo "" +echo "==========================================" +echo "" + +# Test 2: Describe repo +echo "2. Testing com.atproto.repo.describeRepo" +echo " GET $HOLD_URL/xrpc/com.atproto.repo.describeRepo?repo=$DID" +echo " Response:" +curl -s -w "\n HTTP Status: %{http_code}\n" \ + "$HOLD_URL/xrpc/com.atproto.repo.describeRepo?repo=$DID" | jq . 2>/dev/null || cat +echo "" +echo "==========================================" +echo "" + +# Test 3: Get repository (CAR file) +echo "3. Testing com.atproto.sync.getRepo" +echo " GET $HOLD_URL/xrpc/com.atproto.sync.getRepo?did=$DID" +echo " Response (showing first 200 bytes of CAR file):" +curl -s -w "\n HTTP Status: %{http_code}\n Content-Type: %{content_type}\n" \ + "$HOLD_URL/xrpc/com.atproto.sync.getRepo?did=$DID" | head -c 200 +echo "" +echo " [truncated...]" +echo "" +echo "==========================================" +echo "" + +# Test 4: List records (captain record) +echo "4. Testing com.atproto.repo.listRecords (captain)" +echo " GET $HOLD_URL/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.hold.captain" +echo " Response:" +curl -s -w "\n HTTP Status: %{http_code}\n" \ + "$HOLD_URL/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.hold.captain" | jq . 2>/dev/null || cat +echo "" +echo "==========================================" +echo "" + +# Test 5: List records (crew records) +echo "5. Testing com.atproto.repo.listRecords (crew)" +echo " GET $HOLD_URL/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.hold.crew" +echo " Response:" +curl -s -w "\n HTTP Status: %{http_code}\n" \ + "$HOLD_URL/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.hold.crew" | jq . 2>/dev/null || cat +echo "" +echo "==========================================" +echo "" + +# Test 6: Get blob (this will likely return an error without a valid CID) +echo "6. Testing com.atproto.sync.getBlob (requires valid CID)" +echo " Skipping - needs a real blob CID from your hold" +echo " Example command:" +echo " curl \"$HOLD_URL/xrpc/com.atproto.sync.getBlob?did=$DID&cid=bafyrei...\"" +echo "" +echo "==========================================" +echo "" + +# Test 7: WebSocket - subscribeRepos +echo "7. Testing com.atproto.sync.subscribeRepos (WebSocket)" +echo " This requires a WebSocket client like websocat or wscat" +echo "" + +# Check if websocat is available +if command -v websocat &> /dev/null; then + echo " Found websocat! Connecting for 5 seconds..." + WS_URL="${HOLD_URL/http:/ws:}" + WS_URL="${WS_URL/https:/wss:}" + echo " WS URL: $WS_URL/xrpc/com.atproto.sync.subscribeRepos" + timeout 5 websocat "$WS_URL/xrpc/com.atproto.sync.subscribeRepos" 2>&1 || echo " Connection closed or timeout" +elif command -v wscat &> /dev/null; then + echo " Found wscat! Connecting for 5 seconds..." + WS_URL="${HOLD_URL/http:/ws:}" + WS_URL="${WS_URL/https:/wss:}" + echo " WS URL: $WS_URL/xrpc/com.atproto.sync.subscribeRepos" + timeout 5 wscat -c "$WS_URL/xrpc/com.atproto.sync.subscribeRepos" 2>&1 || echo " Connection closed or timeout" +else + echo " WebSocket client not found. Install websocat or wscat:" + echo " - websocat: cargo install websocat" + echo " - wscat: npm install -g wscat" + echo "" + echo " Manual test command:" + WS_URL="${HOLD_URL/http:/ws:}" + WS_URL="${WS_URL/https:/wss:}" + echo " websocat '$WS_URL/xrpc/com.atproto.sync.subscribeRepos'" + echo " OR" + echo " wscat -c '$WS_URL/xrpc/com.atproto.sync.subscribeRepos'" +fi + +echo "" +echo "==========================================" +echo "Tests complete!" +echo "" +echo "Note: If you need to test with a specific blob, first push an image" +echo "to get real blob CIDs, then use them with getBlob endpoint."