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

This commit is contained in:
Evan Jarrett
2025-10-22 11:08:13 -05:00
parent a118904cb8
commit 3e9a496a5d
23 changed files with 1080 additions and 693 deletions
+9 -2
View File
@@ -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)
}
+1 -127
View File
@@ -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
+218
View File
@@ -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 <container> | 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
+78
View File
@@ -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
}
@@ -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;
@@ -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);
+12 -21
View File
@@ -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
+72 -258
View File
@@ -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)
}
+156 -90
View File
@@ -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
+12 -7
View File
@@ -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,
+10 -9
View File
@@ -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
+28 -27
View File
@@ -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)
+6
View File
@@ -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;
+6 -1
View File
@@ -43,8 +43,13 @@
</div>
<!-- Metadata Section -->
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }}
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }}
<div class="repo-metadata">
{{ if .Repository.Version }}
<span class="metadata-badge version-badge" title="Version">
{{ .Repository.Version }}
</span>
{{ end }}
{{ if .Repository.Licenses }}
{{ range parseLicenses .Repository.Licenses }}
{{ if .IsValid }}
+5
View File
@@ -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
+1 -1
View File
@@ -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)
}
+10 -10
View File
@@ -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)
}
+174
View File
@@ -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
}
+34 -8
View File
@@ -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
}
+10 -10
View File
@@ -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)
}
+60 -41
View File
@@ -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
+25 -36
View File
@@ -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, "")
}
}
+112
View File
@@ -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."