mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-21 09:44:15 +00:00
refactor how annotations are stored. add ability to create bsky profile for hold user
This commit is contained in:
+9
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+112
@@ -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."
|
||||
Reference in New Issue
Block a user