mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-21 01:34:16 +00:00
ui fixes, add ability to warn/hide unreachable manifests from the ui. clean up docs
This commit is contained in:
@@ -19,9 +19,19 @@ go build -o bin/oauth-helper ./cmd/oauth-helper
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# Run tests for specific package
|
||||
go test ./pkg/atproto/...
|
||||
go test ./pkg/appview/storage/...
|
||||
|
||||
# Run specific test
|
||||
go test -run TestManifestStore ./pkg/atproto/...
|
||||
|
||||
# Run with race detector
|
||||
go test -race ./...
|
||||
|
||||
# Run tests with verbose output
|
||||
go test -v ./...
|
||||
|
||||
# Update dependencies
|
||||
go mod tidy
|
||||
|
||||
@@ -101,32 +111,45 @@ ATCR uses **distribution/distribution** as a library and extends it through midd
|
||||
2. HTTP Request → /v2/alice/myapp/manifests/latest
|
||||
3. Registry Middleware (pkg/appview/middleware/registry.go)
|
||||
→ Resolves "alice" to DID and PDS endpoint
|
||||
→ Queries alice's sailor profile for defaultHold
|
||||
→ Queries alice's sailor profile for defaultHold (returns DID if set)
|
||||
→ If not set, checks alice's io.atcr.hold records
|
||||
→ Falls back to AppView's default_storage_endpoint
|
||||
→ Stores DID/PDS/storage endpoint in context
|
||||
→ Falls back to AppView's default_hold_did
|
||||
→ Stores DID/PDS/hold DID in RegistryContext
|
||||
4. Routing Repository (pkg/appview/storage/routing_repository.go)
|
||||
→ Creates RoutingRepository
|
||||
→ Returns ATProto ManifestStore for manifests
|
||||
→ Returns ProxyBlobStore for blobs
|
||||
5. Blob PUT → Resolved hold service (redirects to S3/storage)
|
||||
6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdEndpoint)
|
||||
→ Returns ProxyBlobStore for blobs (routes to hold DID)
|
||||
5. Blob PUT → ProxyBlobStore calls hold's XRPC multipart upload endpoints:
|
||||
a. POST /xrpc/io.atcr.hold.initiateUpload (gets uploadID)
|
||||
b. POST /xrpc/io.atcr.hold.getPartUploadUrl (gets presigned URL for each part)
|
||||
c. PUT to S3 presigned URL (or PUT /xrpc/io.atcr.hold.uploadPart for buffered mode)
|
||||
d. POST /xrpc/io.atcr.hold.completeUpload (finalizes upload)
|
||||
6. Manifest PUT → alice's PDS as io.atcr.manifest record (includes holdDid + holdEndpoint)
|
||||
→ Manifest also uploaded to PDS blob storage (ATProto CID format)
|
||||
```
|
||||
|
||||
#### Push with BYOS (Bring Your Own Storage)
|
||||
```
|
||||
1. Client: docker push atcr.io/alice/myapp:latest
|
||||
2. Registry Middleware resolves alice → did:plc:alice123
|
||||
3. Hold discovery via findStorageEndpoint():
|
||||
a. Check alice's sailor profile for defaultHold
|
||||
b. If not set, check alice's io.atcr.hold records
|
||||
c. Fall back to AppView's default_storage_endpoint
|
||||
4. Found: alice's profile has defaultHold = "https://alice-storage.fly.dev"
|
||||
5. Routing Repository returns ProxyBlobStore(alice-storage.fly.dev)
|
||||
6. ProxyBlobStore calls alice-storage.fly.dev for presigned URL
|
||||
7. Storage service validates alice's DID, generates S3 presigned URL
|
||||
8. Client redirected to upload blob directly to alice's S3/Storj
|
||||
9. Manifest stored in alice's PDS with holdEndpoint = "https://alice-storage.fly.dev"
|
||||
3. Hold discovery via findHoldDID():
|
||||
a. Check alice's sailor profile for defaultHold (returns DID if set)
|
||||
b. If not set, check alice's io.atcr.hold records (legacy)
|
||||
c. Fall back to AppView's default_hold_did
|
||||
4. Found: alice's profile has defaultHold = "did:web:alice-storage.fly.dev"
|
||||
5. Routing Repository returns ProxyBlobStore(did:web:alice-storage.fly.dev)
|
||||
6. ProxyBlobStore:
|
||||
a. Resolves hold DID → https://alice-storage.fly.dev (did:web resolution)
|
||||
b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth
|
||||
c. Calls hold XRPC endpoints with service token authentication:
|
||||
- POST /xrpc/io.atcr.hold.initiateUpload
|
||||
- POST /xrpc/io.atcr.hold.getPartUploadUrl (returns presigned S3 URL)
|
||||
- PUT to S3 presigned URL (direct upload to alice's S3/Storj)
|
||||
- POST /xrpc/io.atcr.hold.completeUpload
|
||||
7. Hold service validates service token, checks crew membership, generates presigned URLs
|
||||
8. Manifest stored in alice's PDS with:
|
||||
- holdDid = "did:web:alice-storage.fly.dev" (primary)
|
||||
- holdEndpoint = "https://alice-storage.fly.dev" (backward compat)
|
||||
```
|
||||
|
||||
#### Pull Flow
|
||||
@@ -134,15 +157,22 @@ ATCR uses **distribution/distribution** as a library and extends it through midd
|
||||
1. Client: docker pull atcr.io/alice/myapp:latest
|
||||
2. GET /v2/alice/myapp/manifests/latest
|
||||
3. AppView fetches manifest from alice's PDS
|
||||
4. Manifest contains holdEndpoint = "https://alice-storage.fly.dev"
|
||||
5. Hold endpoint cached: (alice's DID, "myapp") → "https://alice-storage.fly.dev"
|
||||
4. Manifest contains:
|
||||
- holdDid = "did:web:alice-storage.fly.dev" (primary reference)
|
||||
- holdEndpoint = "https://alice-storage.fly.dev" (legacy fallback)
|
||||
5. Hold DID cached: (alice's DID, "myapp") → "did:web:alice-storage.fly.dev"
|
||||
TTL: 10 minutes (covers typical pull operations)
|
||||
6. Client requests blobs: GET /v2/alice/myapp/blobs/sha256:abc123
|
||||
7. AppView checks cache, routes to hold from manifest (not re-discovered)
|
||||
8. ProxyBlobStore calls alice-storage.fly.dev for presigned download URL
|
||||
9. Client redirected to download blob directly from alice's S3
|
||||
7. AppView checks cache, routes to hold DID from manifest (not re-discovered)
|
||||
8. ProxyBlobStore:
|
||||
a. Resolves hold DID → https://alice-storage.fly.dev
|
||||
b. Gets service token from alice's PDS via com.atproto.server.getServiceAuth
|
||||
c. Calls GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123&method=GET
|
||||
d. Hold returns presigned download URL in JSON response
|
||||
9. Client redirected to download blob directly from alice's S3 via presigned URL
|
||||
```
|
||||
|
||||
**Key insight:** Pull uses the historical `holdEndpoint` from the manifest, ensuring blobs are fetched from the hold where they were originally pushed, even if alice later changes her default hold.
|
||||
**Key insight:** Pull uses the historical `holdDid` from the manifest, ensuring blobs are fetched from the hold where they were originally pushed, even if alice later changes her default hold. Hold cache (10min TTL) avoids re-querying PDS for each blob during the same pull operation.
|
||||
|
||||
### Name Resolution
|
||||
|
||||
@@ -269,12 +299,14 @@ Later (subsequent docker push):
|
||||
- Uses XRPC protocol (com.atproto.repo.*)
|
||||
|
||||
**lexicon.go**: ATProto record schemas
|
||||
- `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdEndpoint` field)
|
||||
- `ManifestRecord`: OCI manifest stored as ATProto record (includes `holdDid` + `holdEndpoint` fields)
|
||||
- `TagRecord`: Tag pointing to manifest digest
|
||||
- `HoldRecord`: Storage hold definition (for BYOS)
|
||||
- `HoldCrewRecord`: Hold crew membership/permissions
|
||||
- `SailorProfileRecord`: User profile with `defaultHold` preference
|
||||
- Collections: `io.atcr.manifest`, `io.atcr.tag`, `io.atcr.hold`, `io.atcr.hold.crew`, `io.atcr.sailor.profile`
|
||||
- `HoldRecord`: Storage hold definition (LEGACY - for old BYOS model)
|
||||
- `HoldCrewRecord`: Hold crew membership (LEGACY - stored in owner's PDS)
|
||||
- `CaptainRecord`: Hold ownership record (NEW - stored in hold's embedded PDS at rkey "self")
|
||||
- `CrewRecord`: Hold crew membership (NEW - stored in hold's embedded PDS, one record per member)
|
||||
- `SailorProfileRecord`: User profile with `defaultHold` preference (can be DID or URL)
|
||||
- Collections: `io.atcr.manifest`, `io.atcr.tag`, `io.atcr.hold` (legacy), `io.atcr.hold.crew` (used by both legacy and new models), `io.atcr.hold.captain` (new), `io.atcr.sailor.profile`
|
||||
|
||||
**profile.go**: Sailor profile management
|
||||
- `EnsureProfile()`: Creates profile with default hold on first authentication
|
||||
@@ -289,26 +321,29 @@ Later (subsequent docker push):
|
||||
#### Storage Layer (`pkg/appview/storage/`)
|
||||
|
||||
**routing_repository.go**: Routes content by type
|
||||
- `Manifests()` → returns ATProto ManifestStore (caches instance for hold endpoint extraction)
|
||||
- `Manifests()` → returns ATProto ManifestStore (caches instance for hold DID extraction)
|
||||
- `Blobs()` → checks hold cache for pull, uses discovery for push
|
||||
- Pull: Uses cached `holdEndpoint` from manifest (historical reference)
|
||||
- Push: Uses discovery-based endpoint from `findStorageEndpoint()`
|
||||
- Always returns ProxyBlobStore (routes to hold service)
|
||||
- Pull: Uses cached `holdDid` from manifest (historical reference)
|
||||
- Push: Uses discovery-based DID from `findHoldDID()` in middleware
|
||||
- Always returns ProxyBlobStore (routes to hold service via DID)
|
||||
- Implements `distribution.Repository` interface
|
||||
- Uses RegistryContext to pass DID, PDS endpoint, hold DID, OAuth refresher, etc.
|
||||
|
||||
**hold_cache.go**: In-memory hold endpoint cache
|
||||
- Caches `(DID, repository) → holdEndpoint` for pull operations
|
||||
**hold_cache.go**: In-memory hold DID cache
|
||||
- Caches `(DID, repository) → holdDid` for pull operations
|
||||
- TTL: 10 minutes (covers typical pull operations)
|
||||
- Cleanup: Background goroutine runs every 5 minutes
|
||||
- **NOTE:** Simple in-memory cache for MVP. For production: use Redis or similar
|
||||
- Prevents expensive ATProto lookups on every blob request
|
||||
- Prevents expensive PDS manifest lookups on every blob request during pull
|
||||
|
||||
**proxy_blob_store.go**: External storage proxy
|
||||
- Calls user's storage service for presigned URLs
|
||||
- Issues HTTP redirects for blob uploads/downloads
|
||||
**proxy_blob_store.go**: External storage proxy (routes to hold via XRPC)
|
||||
- Resolves hold DID → HTTP URL for XRPC requests (did:web resolution)
|
||||
- Gets service tokens from user's PDS (`com.atproto.server.getServiceAuth`)
|
||||
- Calls hold XRPC endpoints with service token authentication:
|
||||
- Multipart upload: initiateUpload, getPartUploadUrl, uploadPart, completeUpload, abortUpload
|
||||
- Blob read: com.atproto.sync.getBlob (returns presigned download URL)
|
||||
- Implements full `distribution.BlobStore` interface
|
||||
- Supports multipart uploads for large blobs
|
||||
- Used when user has `io.atcr.hold` record
|
||||
- Supports both presigned URL mode (S3 direct) and buffered mode (proxy via hold)
|
||||
|
||||
#### AppView Web UI (`pkg/appview/`)
|
||||
|
||||
@@ -348,46 +383,73 @@ The AppView includes a web interface for browsing the registry:
|
||||
|
||||
#### Hold Service (`cmd/hold/`)
|
||||
|
||||
Lightweight standalone service for BYOS (Bring Your Own Storage):
|
||||
Lightweight standalone service for BYOS (Bring Your Own Storage) with embedded PDS:
|
||||
|
||||
**Architecture:**
|
||||
- Reuses distribution's storage driver factory
|
||||
- Supports all distribution drivers: S3, Storj, Minio, Azure, GCS, filesystem
|
||||
- Authorization follows ATProto's public-by-default model
|
||||
- Generates presigned URLs (15min expiry) or proxies uploads/downloads
|
||||
- **Embedded PDS**: Each hold has a full ATProto PDS for storing captain + crew records
|
||||
- **DID**: Hold identified by did:web (e.g., `did:web:hold01.atcr.io`)
|
||||
- **Storage**: Reuses distribution's storage driver factory (S3, Storj, Minio, Azure, GCS, filesystem)
|
||||
- **Authorization**: Based on captain + crew records in embedded PDS
|
||||
- **Blob operations**: Generates presigned URLs (15min expiry) or proxies uploads/downloads via XRPC
|
||||
|
||||
**Authorization Model:**
|
||||
|
||||
Read access:
|
||||
- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + all authenticated users
|
||||
- **Private hold** (`HOLD_PUBLIC=false`): Authenticated users only (any ATCR user)
|
||||
- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication + crew membership with blob:read permission
|
||||
|
||||
Write access:
|
||||
- Hold owner OR crew members only
|
||||
- Hold owner OR crew members with blob:write permission
|
||||
- Verified via `io.atcr.hold.crew` records in hold's embedded PDS
|
||||
|
||||
Key insight: "Private" gates anonymous access, not authenticated access. This reflects ATProto's current limitation (no private PDS records yet).
|
||||
|
||||
**Embedded PDS Endpoints:**
|
||||
|
||||
Each hold service includes an embedded PDS (Personal Data Server) that stores captain + crew records:
|
||||
**Embedded PDS Endpoints** (`pkg/hold/pds/xrpc.go`):
|
||||
|
||||
Standard ATProto sync endpoints:
|
||||
- `GET /xrpc/com.atproto.sync.getRepo?did={did}` - Download full repository as CAR file
|
||||
- `GET /xrpc/com.atproto.sync.getRepo?did={did}&since={rev}` - Download repository diff since revision
|
||||
- `GET /xrpc/com.atproto.sync.subscribeRepos` - WebSocket firehose for real-time events
|
||||
- `GET /xrpc/com.atproto.sync.listRepos` - List all repositories (single-user PDS)
|
||||
- `GET /xrpc/com.atproto.sync.getBlob?did={did}&cid={digest}` - Get blob or presigned download URL
|
||||
|
||||
Repository management:
|
||||
- `GET /xrpc/com.atproto.repo.describeRepo?repo={did}` - Repository metadata
|
||||
- `GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection={col}&rkey={key}` - Get record
|
||||
- `GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection={col}` - List records (supports pagination)
|
||||
- `POST /xrpc/com.atproto.repo.deleteRecord` - Delete record (owner/crew admin only)
|
||||
- `POST /xrpc/com.atproto.repo.uploadBlob` - Upload ATProto blob (owner/crew admin only)
|
||||
|
||||
DID resolution:
|
||||
- `GET /.well-known/did.json` - DID document (did:web resolution)
|
||||
- Standard ATProto repo endpoints (getRecord, listRecords, etc.)
|
||||
- `GET /.well-known/atproto-did` - DID for handle resolution
|
||||
|
||||
The `subscribeRepos` endpoint broadcasts #commit events whenever crew membership changes, allowing AppViews to monitor hold access control in real-time.
|
||||
Crew management:
|
||||
- `POST /xrpc/io.atcr.hold.requestCrew` - Request crew membership (authenticated users)
|
||||
|
||||
**Configuration:** Environment variables (see `.env.example`)
|
||||
- `HOLD_PUBLIC_URL` - Public URL of hold service (required)
|
||||
**OCI Multipart Upload Endpoints** (`pkg/hold/oci/xrpc.go`):
|
||||
|
||||
All require blob:write permission via service token authentication:
|
||||
- `POST /xrpc/io.atcr.hold.initiateUpload` - Start multipart upload session
|
||||
- `POST /xrpc/io.atcr.hold.getPartUploadUrl` - Get presigned URL for uploading a part
|
||||
- `PUT /xrpc/io.atcr.hold.uploadPart` - Direct buffered part upload (alternative to presigned URLs)
|
||||
- `POST /xrpc/io.atcr.hold.completeUpload` - Finalize multipart upload and move to final location
|
||||
- `POST /xrpc/io.atcr.hold.abortUpload` - Cancel multipart upload and cleanup temp data
|
||||
|
||||
**AppView-to-Hold Authentication:**
|
||||
- AppView uses service tokens from user's PDS (`com.atproto.server.getServiceAuth`)
|
||||
- Service tokens are scoped to specific hold DIDs and include the user's DID
|
||||
- Hold validates tokens and checks crew membership for authorization
|
||||
- Tokens cached for 50 seconds (valid for 60 seconds from PDS)
|
||||
|
||||
**Configuration:** Environment variables (see `.env.hold.example`)
|
||||
- `HOLD_PUBLIC_URL` - Public URL of hold service (required, used for did:web generation)
|
||||
- `STORAGE_DRIVER` - Storage driver type (s3, filesystem)
|
||||
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials
|
||||
- `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration
|
||||
- `HOLD_PUBLIC` - Allow public reads (default: false)
|
||||
- `HOLD_OWNER` - DID for auto-registration (optional)
|
||||
- `HOLD_OWNER` - DID for captain record creation (optional)
|
||||
- `HOLD_ALLOW_ALL_CREW` - Allow any authenticated user to register as crew (default: false)
|
||||
- `HOLD_DATABASE_PATH` - Path for embedded PDS database (required)
|
||||
- `HOLD_DATABASE_KEY_PATH` - Path for PDS signing keys (optional, generated if missing)
|
||||
|
||||
**Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc.
|
||||
|
||||
@@ -399,17 +461,29 @@ Manifests are stored as records with this structure:
|
||||
"$type": "io.atcr.manifest",
|
||||
"repository": "myapp",
|
||||
"digest": "sha256:abc123...",
|
||||
"holdEndpoint": "https://hold1.alice.com",
|
||||
"holdDid": "did:web:hold01.atcr.io",
|
||||
"holdEndpoint": "https://hold1.atcr.io",
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"config": { "digest": "sha256:...", "size": 1234 },
|
||||
"layers": [
|
||||
{ "digest": "sha256:...", "size": 5678 }
|
||||
],
|
||||
"manifestBlob": {
|
||||
"$type": "blob",
|
||||
"ref": { "$link": "bafyrei..." },
|
||||
"mimeType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"size": 1234
|
||||
},
|
||||
"createdAt": "2025-09-30T..."
|
||||
}
|
||||
```
|
||||
|
||||
**Key fields:**
|
||||
- `holdDid` - DID of the hold service where blobs are stored (PRIMARY reference, new)
|
||||
- `holdEndpoint` - HTTP URL of hold service (DEPRECATED, kept for backward compatibility)
|
||||
- `manifestBlob` - Reference to manifest blob in ATProto blob storage (CID format)
|
||||
|
||||
Record key = manifest digest (without algorithm prefix)
|
||||
Collection = `io.atcr.manifest`
|
||||
|
||||
@@ -425,7 +499,7 @@ ATCR uses a "sailor profile" to manage user preferences for hold (storage) selec
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "https://hold1.alice.com",
|
||||
"defaultHold": "did:web:hold1.alice.com",
|
||||
"createdAt": "2025-10-02T...",
|
||||
"updatedAt": "2025-10-02T..."
|
||||
}
|
||||
@@ -433,14 +507,15 @@ ATCR uses a "sailor profile" to manage user preferences for hold (storage) selec
|
||||
|
||||
**Profile Management:**
|
||||
- Created automatically on first authentication (OAuth or Basic Auth)
|
||||
- If AppView has `default_storage_endpoint` configured, profile gets that as `defaultHold`
|
||||
- `defaultHold` can be a DID (preferred, e.g., `did:web:hold01.atcr.io`) or legacy URL
|
||||
- If AppView has `default_hold_did` configured, profile gets that as `defaultHold`
|
||||
- Users can update their profile to change default hold (future: via UI)
|
||||
- Setting `defaultHold` to null opts out of defaults (use own holds or AppView default)
|
||||
|
||||
**Hold Resolution Priority** (in `findStorageEndpoint()`):
|
||||
1. **Profile's `defaultHold`** - User's explicit preference
|
||||
2. **User's `io.atcr.hold` records** - User's own holds
|
||||
3. **AppView's `default_storage_endpoint`** - Fallback default
|
||||
**Hold Resolution Priority** (in `findHoldDID()` in middleware):
|
||||
1. **Profile's `defaultHold`** - User's explicit preference (DID or URL)
|
||||
2. **User's `io.atcr.hold` records** - User's own holds (legacy BYOS model)
|
||||
3. **AppView's `default_hold_did`** - Fallback default (configured in middleware)
|
||||
|
||||
This ensures:
|
||||
- Users can join shared holds by setting their profile's `defaultHold`
|
||||
@@ -472,7 +547,7 @@ See `.env.appview.example` for all available options. Key environment variables:
|
||||
**Server:**
|
||||
- `ATCR_HTTP_ADDR` - HTTP listen address (default: `:5000`)
|
||||
- `ATCR_BASE_URL` - Public URL for OAuth/JWT realm (auto-detected in dev)
|
||||
- `ATCR_DEFAULT_HOLD` - Default hold endpoint for blob storage (REQUIRED)
|
||||
- `ATCR_DEFAULT_HOLD_DID` - Default hold DID for blob storage (REQUIRED, e.g., `did:web:hold01.atcr.io`)
|
||||
|
||||
**Authentication:**
|
||||
- `ATCR_AUTH_KEY_PATH` - JWT signing key path (default: `/var/lib/atcr/auth/private-key.pem`)
|
||||
@@ -537,12 +612,12 @@ When writing tests:
|
||||
**Modifying storage routing**:
|
||||
1. Edit `pkg/appview/storage/routing_repository.go`
|
||||
2. Update `Blobs()` method to change routing logic
|
||||
3. Consider context values: `storage.endpoint`, `atproto.did`
|
||||
3. Context is passed via RegistryContext struct (holds DID, PDS endpoint, hold DID, OAuth refresher, etc.)
|
||||
|
||||
**Changing name resolution**:
|
||||
1. Modify `pkg/atproto/resolver.go` for DID/handle resolution
|
||||
2. Update `pkg/appview/middleware/registry.go` if changing routing logic
|
||||
3. Remember: `findStorageEndpoint()` queries PDS for `io.atcr.hold` records
|
||||
3. Remember: `findHoldDID()` checks sailor profile, then `io.atcr.hold` records (legacy), then default hold DID
|
||||
|
||||
**Working with OAuth client**:
|
||||
- Client is self-contained: pass `baseURL`, it handles client ID/redirect URI/scopes
|
||||
@@ -582,13 +657,20 @@ When writing tests:
|
||||
|
||||
## Important Context Values
|
||||
|
||||
When working with the codebase, these context values are used for routing:
|
||||
When working with the codebase, routing information is passed via the `RegistryContext` struct (`pkg/appview/storage/context.go`):
|
||||
|
||||
- `atproto.did` - Resolved DID for the user (e.g., `did:plc:alice123`)
|
||||
- `atproto.pds` - User's PDS endpoint (e.g., `https://bsky.social`)
|
||||
- `atproto.identity` - Original identity string (handle or DID)
|
||||
- `storage.endpoint` - Storage service URL (if user has `io.atcr.registry` record)
|
||||
- `auth.did` - Authenticated DID from validated token
|
||||
- `DID` - User's DID (e.g., `did:plc:alice123`)
|
||||
- `PDSEndpoint` - User's PDS endpoint (e.g., `https://bsky.social`)
|
||||
- `HoldDID` - Hold service DID (e.g., `did:web:hold01.atcr.io`)
|
||||
- `Repository` - Image repository name (e.g., `myapp`)
|
||||
- `ATProtoClient` - Client for calling user's PDS with OAuth/Basic Auth
|
||||
- `Refresher` - OAuth token refresher for service token requests
|
||||
- `Database` - Database for metrics tracking
|
||||
- `Authorizer` - Hold authorizer for access control
|
||||
|
||||
Legacy context keys (deprecated):
|
||||
- `hold.did` - Hold DID (now in RegistryContext)
|
||||
- `auth.did` - Authenticated DID from validated token (now in auth middleware)
|
||||
|
||||
## Documentation References
|
||||
|
||||
|
||||
@@ -21,20 +21,23 @@ atcr.io/did:plc:xyz123/myapp:latest
|
||||
1. **AppView** - Registry API + web UI
|
||||
- Serves OCI Distribution API (Docker push/pull)
|
||||
- Resolves handles/DIDs to PDS endpoints
|
||||
- Routes manifests to PDS, blobs to storage
|
||||
- Routes manifests to user's PDS, blobs to hold services
|
||||
- Web interface for browsing/search
|
||||
|
||||
2. **Hold Service** - Storage service (optional BYOS)
|
||||
2. **Hold Service** - Storage service with embedded PDS (optional BYOS)
|
||||
- Each hold has a full ATProto PDS for access control (captain + crew records)
|
||||
- Identified by did:web (e.g., `did:web:hold01.atcr.io`)
|
||||
- Generates presigned URLs for S3/Storj/Minio/etc.
|
||||
- Users can deploy their own storage
|
||||
- Users can deploy their own storage and control access via crew membership
|
||||
|
||||
3. **Credential Helper** - Client authentication
|
||||
- ATProto OAuth with DPoP
|
||||
- Automatic authentication on first push/pull
|
||||
|
||||
**Storage model:**
|
||||
- Manifests → ATProto records (small JSON)
|
||||
- Blobs → S3 or BYOS (large binaries)
|
||||
- Manifests → ATProto records in user's PDS (small JSON, includes `holdDid` reference)
|
||||
- Blobs → Hold services via XRPC multipart upload (large binaries, stored in S3/etc.)
|
||||
- AppView uses service tokens to communicate with holds on behalf of users
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
+43
-11
@@ -26,6 +26,7 @@ import (
|
||||
"atcr.io/pkg/appview"
|
||||
"atcr.io/pkg/appview/db"
|
||||
uihandlers "atcr.io/pkg/appview/handlers"
|
||||
"atcr.io/pkg/appview/holdhealth"
|
||||
"atcr.io/pkg/appview/jetstream"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -72,6 +73,22 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("failed to initialize UI database - required for session storage")
|
||||
}
|
||||
|
||||
// Initialize hold health checker
|
||||
fmt.Println("Initializing hold health checker...")
|
||||
cacheTTL := 15 * time.Minute // Cache TTL from user requirements
|
||||
healthChecker := holdhealth.NewChecker(cacheTTL)
|
||||
|
||||
// Start background health check worker
|
||||
refreshInterval := 5 * time.Minute // Refresh every 5 minutes
|
||||
dbAdapter := holdhealth.NewDBAdapter(uiDatabase)
|
||||
healthWorker := holdhealth.NewWorker(healthChecker, dbAdapter, refreshInterval)
|
||||
|
||||
// Create context for worker lifecycle management
|
||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||
defer workerCancel() // Ensure context is cancelled on all exit paths
|
||||
healthWorker.Start(workerCtx)
|
||||
fmt.Println("Hold health worker started (5min refresh interval, 15min cache TTL)")
|
||||
|
||||
// Initialize OAuth components
|
||||
fmt.Println("Initializing OAuth components...")
|
||||
|
||||
@@ -132,8 +149,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
middleware.SetGlobalAuthorizer(holdAuthorizer)
|
||||
fmt.Println("Hold authorizer initialized with database caching")
|
||||
|
||||
// Initialize UI routes with OAuth app, refresher, and device store
|
||||
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID)
|
||||
// Initialize UI routes with OAuth app, refresher, device store, and health checker
|
||||
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker)
|
||||
|
||||
// Create OAuth server
|
||||
oauthServer := oauth.NewServer(oauthApp)
|
||||
@@ -256,6 +273,11 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
select {
|
||||
case <-stop:
|
||||
fmt.Println("Shutting down registry server...")
|
||||
|
||||
// Stop health worker first
|
||||
fmt.Println("Stopping hold health worker...")
|
||||
healthWorker.Stop()
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -263,6 +285,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("server shutdown error: %w", err)
|
||||
}
|
||||
case err := <-errChan:
|
||||
// Stop health worker on error (workerCancel called by defer)
|
||||
healthWorker.Stop()
|
||||
return fmt.Errorf("server error: %w", err)
|
||||
}
|
||||
|
||||
@@ -320,7 +344,8 @@ func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, erro
|
||||
// database: read-write connection for auth and writes
|
||||
// readOnlyDB: read-only connection for public queries (search, user pages, etc.)
|
||||
// defaultHoldDID: DID of the default hold service (e.g., "did:web:hold01.atcr.io")
|
||||
func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string) (*template.Template, *mux.Router) {
|
||||
// healthChecker: hold endpoint health checker
|
||||
func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker) (*template.Template, *mux.Router) {
|
||||
// Check if UI is enabled
|
||||
uiEnabled := os.Getenv("ATCR_UI_ENABLED")
|
||||
if uiEnabled == "false" {
|
||||
@@ -356,9 +381,10 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S
|
||||
|
||||
router.Handle("/api/recent-pushes", middleware.OptionalAuth(sessionStore, database)(
|
||||
&uihandlers.RecentPushesHandler{
|
||||
DB: readOnlyDB,
|
||||
Templates: templates,
|
||||
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
|
||||
DB: readOnlyDB,
|
||||
Templates: templates,
|
||||
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
|
||||
HealthChecker: healthChecker,
|
||||
},
|
||||
)).Methods("GET")
|
||||
|
||||
@@ -428,6 +454,11 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S
|
||||
},
|
||||
)).Methods("GET")
|
||||
|
||||
// Manifest health check API endpoint (HTMX polling)
|
||||
router.Handle("/api/manifest-health", &uihandlers.ManifestHealthHandler{
|
||||
HealthChecker: healthChecker,
|
||||
}).Methods("GET")
|
||||
|
||||
router.Handle("/u/{handle}", middleware.OptionalAuth(sessionStore, database)(
|
||||
&uihandlers.UserPageHandler{
|
||||
DB: readOnlyDB,
|
||||
@@ -438,11 +469,12 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S
|
||||
|
||||
router.Handle("/r/{handle}/{repository}", middleware.OptionalAuth(sessionStore, database)(
|
||||
&uihandlers.RepositoryPageHandler{
|
||||
DB: readOnlyDB,
|
||||
Templates: templates,
|
||||
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
|
||||
Directory: oauthApp.Directory(),
|
||||
Refresher: refresher,
|
||||
DB: readOnlyDB,
|
||||
Templates: templates,
|
||||
RegistryURL: uihandlers.TrimRegistryURL(baseURL),
|
||||
Directory: oauthApp.Directory(),
|
||||
Refresher: refresher,
|
||||
HealthChecker: healthChecker,
|
||||
},
|
||||
)).Methods("GET")
|
||||
|
||||
|
||||
+237
-387
@@ -2,216 +2,138 @@
|
||||
|
||||
## Overview
|
||||
|
||||
ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. This allows users to:
|
||||
- Deploy their own storage service backed by S3/Storj/Minio/filesystem
|
||||
- Control who can use their storage (public or private)
|
||||
- Keep blob data in their own infrastructure while manifests remain in their ATProto PDS
|
||||
ATCR supports "Bring Your Own Storage" (BYOS) for blob storage. Users can:
|
||||
- Deploy their own hold service with embedded PDS
|
||||
- Control access via crew membership in the hold's PDS
|
||||
- Keep blob data in their own S3/Storj/Minio while manifests stay in their user PDS
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ATCR AppView (API) │
|
||||
│ - Manifests → ATProto PDS │
|
||||
│ - Auth & token validation │
|
||||
│ - Blob routing (issues redirects) │
|
||||
│ - Profile management │
|
||||
└─────────────────┬───────────────────────────┘
|
||||
│
|
||||
│ Hold discovery priority:
|
||||
│ 1. io.atcr.sailor.profile.defaultHold
|
||||
│ 2. io.atcr.hold records
|
||||
│ 3. AppView default_storage_endpoint
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ User's PDS │
|
||||
│ - io.atcr.sailor.profile (hold preference) │
|
||||
│ - io.atcr.hold records (own holds) │
|
||||
│ - io.atcr.manifest records (with holdEP) │
|
||||
└─────────────────┬───────────────────────────┘
|
||||
│
|
||||
│ Redirects to hold
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Storage Service (Hold) │
|
||||
│ - Blob storage (S3/Storj/Minio/filesystem) │
|
||||
│ - Presigned URL generation │
|
||||
│ - Authorization (DID-based) │
|
||||
└─────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────┐
|
||||
│ ATCR AppView (API) │
|
||||
│ - Manifests → User's PDS │
|
||||
│ - Auth & service token management │
|
||||
│ - Blob routing via XRPC │
|
||||
│ - Profile management │
|
||||
└────────────┬─────────────────────────────┘
|
||||
│
|
||||
│ Hold discovery priority:
|
||||
│ 1. io.atcr.sailor.profile.defaultHold (DID)
|
||||
│ 2. io.atcr.hold records (legacy)
|
||||
│ 3. AppView default_hold_did
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ User's PDS │
|
||||
│ - io.atcr.sailor.profile (hold DID) │
|
||||
│ - io.atcr.manifest (with holdDid) │
|
||||
└────────────┬─────────────────────────────┘
|
||||
│
|
||||
│ Service token from user's PDS
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Hold Service (did:web:hold.example.com) │
|
||||
│ ├── Embedded PDS │
|
||||
│ │ ├── Captain record (ownership) │
|
||||
│ │ └── Crew records (access control) │
|
||||
│ ├── XRPC multipart upload endpoints │
|
||||
│ └── Storage driver (S3/Storj/etc.) │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## ATProto Records
|
||||
## Hold Service Components
|
||||
|
||||
### io.atcr.sailor.profile
|
||||
Each hold is a full ATProto actor with:
|
||||
- **DID**: `did:web:hold.example.com` (hold's identity)
|
||||
- **Embedded PDS**: Stores captain + crew records (shared data)
|
||||
- **Storage backend**: S3, Storj, Minio, filesystem, etc.
|
||||
- **XRPC endpoints**: Standard ATProto + custom OCI multipart upload
|
||||
|
||||
**NEW:** User profile for hold selection preferences. Created automatically on first authentication.
|
||||
### Records in Hold's PDS
|
||||
|
||||
**Captain record** (`io.atcr.hold.captain/self`):
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.hold.captain",
|
||||
"owner": "did:plc:alice123",
|
||||
"public": false,
|
||||
"deployedAt": "2025-10-14T...",
|
||||
"region": "iad",
|
||||
"provider": "fly.io"
|
||||
}
|
||||
```
|
||||
|
||||
**Crew records** (`io.atcr.hold.crew/{rkey}`):
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.hold.crew",
|
||||
"member": "did:plc:bob456",
|
||||
"role": "admin",
|
||||
"permissions": ["blob:read", "blob:write"],
|
||||
"addedAt": "2025-10-14T..."
|
||||
}
|
||||
```
|
||||
|
||||
### Sailor Profile (User's PDS)
|
||||
|
||||
Users set their preferred hold in their sailor profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "https://team-hold.example.com",
|
||||
"createdAt": "2025-10-02T12:00:00Z",
|
||||
"updatedAt": "2025-10-02T12:00:00Z"
|
||||
"defaultHold": "did:web:hold.example.com",
|
||||
"createdAt": "2025-10-02T...",
|
||||
"updatedAt": "2025-10-02T..."
|
||||
}
|
||||
```
|
||||
|
||||
**Record key:** Always `"self"` (only one profile per user)
|
||||
|
||||
**Behavior:**
|
||||
- Created automatically when user first authenticates (OAuth or Basic Auth)
|
||||
- If AppView has `default_storage_endpoint`, profile gets that as initial `defaultHold`
|
||||
- User can update to join shared holds or use their own hold
|
||||
- Set `defaultHold` to `null` to opt out of defaults (use own hold or AppView default)
|
||||
|
||||
**This solves the multi-hold problem:** Users who are crew members of multiple holds can explicitly choose which one to use via their profile.
|
||||
|
||||
### io.atcr.hold
|
||||
|
||||
Users create a hold record in their PDS to configure their own storage:
|
||||
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.hold",
|
||||
"endpoint": "https://alice-storage.example.com",
|
||||
"owner": "did:plc:alice123",
|
||||
"public": false,
|
||||
"createdAt": "2025-10-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### io.atcr.hold.crew
|
||||
|
||||
Hold owners can add crew members (for shared storage):
|
||||
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.hold.crew",
|
||||
"hold": "at://did:plc:alice/io.atcr.hold/my-storage",
|
||||
"member": "did:plc:bob456",
|
||||
"role": "write",
|
||||
"addedAt": "2025-10-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Crew records are stored in the **hold owner's PDS**, not the crew member's PDS. This ensures the hold owner maintains full control over access.
|
||||
|
||||
## Storage Service
|
||||
|
||||
### Deployment
|
||||
|
||||
The storage service is a lightweight HTTP server that:
|
||||
1. Accepts presigned URL requests
|
||||
2. Verifies DID authorization
|
||||
3. Generates presigned URLs for S3/Storj/etc
|
||||
4. Returns URLs to AppView for client redirect
|
||||
## Deployment
|
||||
|
||||
### Configuration
|
||||
|
||||
The hold service is configured entirely via environment variables. See `.env.example` for all options.
|
||||
|
||||
**Required environment variables:**
|
||||
Hold service is configured entirely via environment variables:
|
||||
|
||||
```bash
|
||||
# Hold service public URL (REQUIRED)
|
||||
HOLD_PUBLIC_URL=https://storage.example.com
|
||||
# Hold identity (REQUIRED)
|
||||
HOLD_PUBLIC_URL=https://hold.example.com
|
||||
HOLD_OWNER=did:plc:your-did-here
|
||||
|
||||
# Storage driver type
|
||||
# Storage backend
|
||||
STORAGE_DRIVER=s3
|
||||
|
||||
# For S3/Minio
|
||||
AWS_ACCESS_KEY_ID=your_access_key
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_key
|
||||
AWS_REGION=us-east-1
|
||||
S3_BUCKET=my-blobs
|
||||
|
||||
# For Storj (optional - custom S3 endpoint)
|
||||
# S3_ENDPOINT=https://gateway.storjshare.io
|
||||
# Access control
|
||||
HOLD_PUBLIC=false # Require authentication for reads
|
||||
HOLD_ALLOW_ALL_CREW=false # Only explicit crew members can write
|
||||
|
||||
# For filesystem storage
|
||||
# STORAGE_DRIVER=filesystem
|
||||
# STORAGE_ROOT_DIR=/var/lib/atcr-storage
|
||||
# Embedded PDS
|
||||
HOLD_DATABASE_PATH=/var/lib/atcr-hold/hold.db
|
||||
HOLD_DATABASE_KEY_PATH=/var/lib/atcr-hold/keys
|
||||
```
|
||||
|
||||
**Authorization:**
|
||||
|
||||
ATCR follows ATProto's public-by-default model with gated anonymous access:
|
||||
|
||||
**Read Access:**
|
||||
- **Public hold** (`HOLD_PUBLIC=true`): Anonymous reads allowed (no authentication)
|
||||
- **Private hold** (`HOLD_PUBLIC=false`): Requires authentication (any ATCR user with sailor.profile)
|
||||
|
||||
**Write Access:**
|
||||
- Always requires authentication
|
||||
- Must be hold owner OR crew member (verified via `io.atcr.hold.crew` records in owner's PDS)
|
||||
|
||||
**Key Points:**
|
||||
- "Private" just means "no anonymous access" - not "limited user access"
|
||||
- Any authenticated ATCR user can read from private holds
|
||||
- Crew membership only controls WRITE access, not READ access
|
||||
- This aligns with ATProto's public records model (no private PDS records yet)
|
||||
|
||||
### Running
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o atcr-hold ./cmd/hold
|
||||
go build -o bin/atcr-hold ./cmd/hold
|
||||
|
||||
# Set environment variables (or use .env file)
|
||||
export HOLD_PUBLIC_URL=https://storage.example.com
|
||||
export STORAGE_DRIVER=s3
|
||||
export AWS_ACCESS_KEY_ID=...
|
||||
export AWS_SECRET_ACCESS_KEY=...
|
||||
export AWS_REGION=us-east-1
|
||||
export S3_BUCKET=my-blobs
|
||||
# Run (with env vars or .env file)
|
||||
export HOLD_PUBLIC_URL=http://localhost:8080
|
||||
export HOLD_OWNER=did:plc:your-did-here
|
||||
export STORAGE_DRIVER=filesystem
|
||||
export STORAGE_ROOT_DIR=/tmp/atcr-hold
|
||||
export HOLD_DATABASE_PATH=/tmp/atcr-hold/hold.db
|
||||
|
||||
# Run
|
||||
./atcr-hold
|
||||
./bin/atcr-hold
|
||||
```
|
||||
|
||||
**Registration (required):**
|
||||
|
||||
The hold service must be registered in a PDS to be discoverable by the AppView.
|
||||
|
||||
**Standard registration workflow:**
|
||||
|
||||
1. Set `HOLD_OWNER` to your DID:
|
||||
```bash
|
||||
export HOLD_OWNER=did:plc:your-did-here
|
||||
```
|
||||
|
||||
2. Start the hold service:
|
||||
```bash
|
||||
./atcr-hold
|
||||
```
|
||||
|
||||
3. **Check the logs** for the OAuth authorization URL:
|
||||
```
|
||||
================================================================================
|
||||
OAUTH AUTHORIZATION REQUIRED
|
||||
================================================================================
|
||||
|
||||
Please visit this URL to authorize the hold service:
|
||||
|
||||
https://bsky.app/authorize?client_id=...
|
||||
|
||||
Waiting for authorization...
|
||||
================================================================================
|
||||
```
|
||||
|
||||
4. Visit the URL in your browser and authorize
|
||||
|
||||
5. The hold service will:
|
||||
- Exchange the authorization code for a token
|
||||
- Create `io.atcr.hold` record in your PDS
|
||||
- Create `io.atcr.hold.crew` record (making you the owner)
|
||||
- Save registration state
|
||||
|
||||
6. On subsequent runs, the service checks if already registered and skips OAuth
|
||||
|
||||
**Alternative methods:**
|
||||
|
||||
- **Manual API registration**: Call `POST /register` with your own OAuth token
|
||||
- **Completely manual**: Create PDS records yourself using any ATProto client
|
||||
On first run, the hold service creates:
|
||||
- Captain record in embedded PDS (making you the owner)
|
||||
- Crew record for owner with all permissions
|
||||
- DID document at `/.well-known/did.json`
|
||||
|
||||
### Deploy to Fly.io
|
||||
|
||||
@@ -223,11 +145,11 @@ primary_region = "ord"
|
||||
|
||||
[env]
|
||||
HOLD_PUBLIC_URL = "https://my-atcr-hold.fly.dev"
|
||||
HOLD_SERVER_ADDR = ":8080"
|
||||
STORAGE_DRIVER = "s3"
|
||||
AWS_REGION = "us-east-1"
|
||||
S3_BUCKET = "my-blobs"
|
||||
HOLD_PUBLIC = "false"
|
||||
HOLD_ALLOW_ALL_CREW = "false"
|
||||
|
||||
[http_service]
|
||||
internal_port = 8080
|
||||
@@ -250,268 +172,196 @@ fly deploy
|
||||
fly secrets set AWS_ACCESS_KEY_ID=...
|
||||
fly secrets set AWS_SECRET_ACCESS_KEY=...
|
||||
fly secrets set HOLD_OWNER=did:plc:your-did-here
|
||||
|
||||
# Check logs for OAuth URL on first run
|
||||
fly logs
|
||||
|
||||
# Visit the OAuth URL shown in logs to authorize
|
||||
# The hold service will register itself in your PDS
|
||||
```
|
||||
|
||||
## Request Flow
|
||||
|
||||
### Push with BYOS
|
||||
|
||||
1. **Docker push** `atcr.io/alice/myapp:latest`
|
||||
2. **AppView** resolves `alice` → `did:plc:alice123`
|
||||
3. **AppView** discovers hold via priority logic:
|
||||
- Check alice's `io.atcr.sailor.profile` for `defaultHold`
|
||||
- If not set, check alice's `io.atcr.hold` records
|
||||
- Fall back to AppView's `default_storage_endpoint`
|
||||
4. **Found:** `alice.profile.defaultHold = "https://team-hold.example.com"`
|
||||
5. **AppView** → team-hold: POST `/put-presigned-url`
|
||||
```json
|
||||
{
|
||||
"did": "did:plc:alice123",
|
||||
"digest": "sha256:abc123...",
|
||||
"size": 1048576
|
||||
}
|
||||
```
|
||||
6. **Hold service**:
|
||||
- Verifies alice is authorized (checks crew records)
|
||||
- Generates S3 presigned upload URL (15min expiry)
|
||||
- Returns: `{"url": "https://s3.../blob?signature=..."}`
|
||||
7. **AppView** → Docker: `307 Redirect` to presigned URL
|
||||
8. **Docker** → S3: PUT blob directly (no proxy)
|
||||
9. **Manifest** stored in alice's PDS with `holdEndpoint: "https://team-hold.example.com"`
|
||||
```
|
||||
1. Client: docker push atcr.io/alice/myapp:latest
|
||||
|
||||
2. AppView resolves alice → did:plc:alice123
|
||||
|
||||
3. AppView discovers hold DID:
|
||||
- Check alice's sailor profile for defaultHold
|
||||
- Returns: "did:web:alice-storage.fly.dev"
|
||||
|
||||
4. AppView gets service token from alice's PDS:
|
||||
GET /xrpc/com.atproto.server.getServiceAuth?aud=did:web:alice-storage.fly.dev
|
||||
Response: { "token": "eyJ..." }
|
||||
|
||||
5. AppView initiates multipart upload to hold:
|
||||
POST https://alice-storage.fly.dev/xrpc/io.atcr.hold.initiateUpload
|
||||
Authorization: Bearer {serviceToken}
|
||||
Body: { "digest": "sha256:abc..." }
|
||||
Response: { "uploadId": "xyz" }
|
||||
|
||||
6. For each part:
|
||||
- AppView: POST /xrpc/io.atcr.hold.getPartUploadUrl
|
||||
- Hold validates service token, checks crew membership
|
||||
- Hold returns: { "url": "https://s3.../presigned" }
|
||||
- Client uploads directly to S3 presigned URL
|
||||
|
||||
7. AppView completes upload:
|
||||
POST /xrpc/io.atcr.hold.completeUpload
|
||||
Body: { "uploadId": "xyz", "digest": "sha256:abc...", "parts": [...] }
|
||||
|
||||
8. Manifest stored in alice's PDS:
|
||||
- holdDid: "did:web:alice-storage.fly.dev"
|
||||
- holdEndpoint: "https://alice-storage.fly.dev" (backward compat)
|
||||
```
|
||||
|
||||
### Pull with BYOS
|
||||
|
||||
1. **Docker pull** `atcr.io/alice/myapp:latest`
|
||||
2. **AppView** fetches manifest from alice's PDS
|
||||
3. **Manifest** contains `holdEndpoint: "https://team-hold.example.com"`
|
||||
4. **AppView** caches: `(alice's DID, "myapp") → "https://team-hold.example.com"` (10min TTL)
|
||||
5. **Docker** requests blobs: GET `/v2/alice/myapp/blobs/sha256:abc123`
|
||||
6. **AppView** uses **cached hold from manifest** (not re-discovered)
|
||||
7. **AppView** → team-hold: POST `/get-presigned-url`
|
||||
8. **Hold service** returns presigned download URL
|
||||
9. **AppView** → Docker: `307 Redirect`
|
||||
10. **Docker** → S3: GET blob directly
|
||||
```
|
||||
1. Client: docker pull atcr.io/alice/myapp:latest
|
||||
|
||||
**Key insight:** Pull uses the historical `holdEndpoint` from the manifest, ensuring blobs are fetched from where they were originally pushed, even if alice later changes her profile's `defaultHold`.
|
||||
2. AppView fetches manifest from alice's PDS
|
||||
|
||||
## Default Registry
|
||||
3. Manifest contains:
|
||||
- holdDid: "did:web:alice-storage.fly.dev"
|
||||
|
||||
The AppView can run its own storage service as the default:
|
||||
4. AppView caches hold DID for 10 minutes (covers pull operation)
|
||||
|
||||
### AppView config
|
||||
5. Client requests blob: GET /v2/alice/myapp/blobs/sha256:abc123
|
||||
|
||||
```yaml
|
||||
middleware:
|
||||
- name: registry
|
||||
options:
|
||||
atproto-resolver:
|
||||
default_storage_endpoint: https://storage.atcr.io
|
||||
6. AppView uses cached hold DID from manifest
|
||||
|
||||
7. AppView gets service token from alice's PDS
|
||||
|
||||
8. AppView calls hold XRPC:
|
||||
GET /xrpc/com.atproto.sync.getBlob?did={userDID}&cid=sha256:abc123
|
||||
Authorization: Bearer {serviceToken}
|
||||
Response: { "url": "https://s3.../presigned-download" }
|
||||
|
||||
9. AppView redirects client to presigned S3 URL
|
||||
|
||||
10. Client downloads directly from S3
|
||||
```
|
||||
|
||||
### Default hold service config
|
||||
**Key insight:** Pull uses the `holdDid` stored in the manifest, ensuring blobs are fetched from where they were originally pushed.
|
||||
|
||||
## Access Control
|
||||
|
||||
### Read Access
|
||||
|
||||
- **Public hold** (`HOLD_PUBLIC=true`): Anonymous + authenticated users
|
||||
- **Private hold** (`HOLD_PUBLIC=false`): Authenticated users with crew membership
|
||||
|
||||
### Write Access
|
||||
|
||||
- Hold owner (captain) OR crew members only
|
||||
- Verified via `io.atcr.hold.crew` records in hold's embedded PDS
|
||||
- Service token proves user identity (from user's PDS)
|
||||
|
||||
### Authorization Flow
|
||||
|
||||
```go
|
||||
1. AppView gets service token from user's PDS
|
||||
2. AppView sends request to hold with service token
|
||||
3. Hold validates service token (checks it's from user's PDS)
|
||||
4. Hold extracts user's DID from token
|
||||
5. Hold checks crew records in its embedded PDS
|
||||
6. If crew member found → allow, else → deny
|
||||
```
|
||||
|
||||
## Managing Crew Members
|
||||
|
||||
### Add Crew Member
|
||||
|
||||
Use ATProto client to create crew record in hold's PDS:
|
||||
|
||||
```bash
|
||||
# Accept any authenticated DID
|
||||
HOLD_PUBLIC=false # Requires authentication
|
||||
# Via XRPC (if hold supports it)
|
||||
POST https://hold.example.com/xrpc/io.atcr.hold.requestCrew
|
||||
Authorization: Bearer {userOAuthToken}
|
||||
|
||||
# Or allow public reads
|
||||
HOLD_PUBLIC=true # Public reads, auth required for writes
|
||||
# Or manually via captain's OAuth to hold's PDS
|
||||
atproto put-record \
|
||||
--pds https://hold.example.com \
|
||||
--collection io.atcr.hold.crew \
|
||||
--rkey "{memberDID}" \
|
||||
--value '{
|
||||
"$type": "io.atcr.hold.crew",
|
||||
"member": "did:plc:bob456",
|
||||
"role": "admin",
|
||||
"permissions": ["blob:read", "blob:write"]
|
||||
}'
|
||||
```
|
||||
|
||||
This provides free-tier shared storage for users who don't want to deploy their own.
|
||||
### Remove Crew Member
|
||||
|
||||
## Storage Drivers Supported
|
||||
```bash
|
||||
atproto delete-record \
|
||||
--pds https://hold.example.com \
|
||||
--collection io.atcr.hold.crew \
|
||||
--rkey "{memberDID}"
|
||||
```
|
||||
|
||||
The storage service uses distribution's storage drivers:
|
||||
## Storage Drivers
|
||||
|
||||
Hold service supports all distribution storage drivers:
|
||||
- **S3** - AWS S3, Minio, Storj (via S3 gateway)
|
||||
- **Filesystem** - Local disk (for testing)
|
||||
- **Azure** - Azure Blob Storage
|
||||
- **GCS** - Google Cloud Storage
|
||||
- **Swift** - OpenStack Swift
|
||||
- **OSS** - Alibaba Cloud OSS
|
||||
|
||||
## Quotas
|
||||
|
||||
Quotas are NOT implemented in the storage service. Instead, use:
|
||||
|
||||
- **S3**: Bucket policies, lifecycle rules
|
||||
- **Storj**: Project limits in Storj dashboard
|
||||
- **Minio**: Quota enforcement features
|
||||
- **Filesystem**: Disk quotas at OS level
|
||||
|
||||
## Security
|
||||
|
||||
### Authorization
|
||||
|
||||
Authorization is based on ATProto's public-by-default model:
|
||||
|
||||
**Read Authorization:**
|
||||
- **Public hold** (`public: true` in hold record):
|
||||
- Anonymous users: ✅ Allowed
|
||||
- Any authenticated user: ✅ Allowed
|
||||
|
||||
- **Private hold** (`public: false` in hold record):
|
||||
- Anonymous users: ❌ 401 Unauthorized
|
||||
- Any authenticated ATCR user: ✅ Allowed (no crew membership required)
|
||||
|
||||
**Write Authorization:**
|
||||
- Anonymous users: ❌ 401 Unauthorized
|
||||
- Authenticated non-crew: ❌ 403 Forbidden
|
||||
- Authenticated crew member: ✅ Allowed
|
||||
- Hold owner: ✅ Allowed
|
||||
|
||||
**Implementation:**
|
||||
- Hold service queries owner's PDS for `io.atcr.hold.crew` records
|
||||
- Crew records are public ATProto records (read without authentication)
|
||||
- "Private" holds only gate anonymous access, not authenticated user access
|
||||
- This reflects ATProto's current limitation: no private PDS records
|
||||
|
||||
### Presigned URLs
|
||||
|
||||
- 15 minute expiry
|
||||
- Client uploads/downloads directly to storage
|
||||
- No data flows through AppView or hold service
|
||||
|
||||
### Private Holds
|
||||
|
||||
"Private" holds gate anonymous access while remaining accessible to authenticated users:
|
||||
|
||||
**What "Private" Means:**
|
||||
- `HOLD_PUBLIC=false` prevents anonymous reads
|
||||
- Any authenticated ATCR user can still read
|
||||
- This aligns with ATProto's public records model
|
||||
|
||||
**Write Control:**
|
||||
- Only hold owner and crew members can write
|
||||
- Crew membership managed via `io.atcr.hold.crew` records in owner's PDS
|
||||
- Removing crew member immediately revokes write access
|
||||
|
||||
**Future: True Private Access**
|
||||
- When ATProto adds private PDS records, ATCR can support truly private repos
|
||||
- For now, "private" = "authenticated-only access"
|
||||
|
||||
## Example: Personal Storage
|
||||
|
||||
Alice wants to use her own Storj account:
|
||||
|
||||
1. **Set environment variables**:
|
||||
```bash
|
||||
export HOLD_PUBLIC_URL=https://alice-storage.fly.dev
|
||||
export HOLD_OWNER=did:plc:alice123
|
||||
export STORAGE_DRIVER=s3
|
||||
export AWS_ACCESS_KEY_ID=your_storj_access_key
|
||||
export AWS_SECRET_ACCESS_KEY=your_storj_secret_key
|
||||
export S3_ENDPOINT=https://gateway.storjshare.io
|
||||
export S3_BUCKET=alice-blobs
|
||||
```
|
||||
|
||||
2. **Deploy hold service** to Fly.io - auto-registration creates hold + crew record
|
||||
|
||||
3. **Push images** - AppView automatically routes to her storage
|
||||
|
||||
## Example: Team Hold
|
||||
|
||||
A company wants shared storage for their team:
|
||||
```bash
|
||||
# 1. Deploy hold service
|
||||
export HOLD_PUBLIC_URL=https://team-hold.fly.dev
|
||||
export HOLD_OWNER=did:plc:admin
|
||||
export HOLD_PUBLIC=false # Private
|
||||
export STORAGE_DRIVER=s3
|
||||
export AWS_ACCESS_KEY_ID=...
|
||||
export S3_BUCKET=team-blobs
|
||||
|
||||
1. **Deploy hold service** with S3 credentials and auto-registration:
|
||||
```bash
|
||||
export HOLD_PUBLIC_URL=https://company-hold.fly.dev
|
||||
export HOLD_OWNER=did:plc:admin
|
||||
export HOLD_PUBLIC=false
|
||||
export STORAGE_DRIVER=s3
|
||||
export AWS_ACCESS_KEY_ID=...
|
||||
export AWS_SECRET_ACCESS_KEY=...
|
||||
export S3_BUCKET=company-blobs
|
||||
```
|
||||
fly deploy
|
||||
|
||||
2. **Hold service auto-registers** on first run, creating:
|
||||
- Hold record in admin's PDS
|
||||
- Crew record making admin the owner
|
||||
# 2. Hold auto-creates captain + crew records on first run
|
||||
|
||||
3. **Admin adds crew members** via ATProto client or manually:
|
||||
```bash
|
||||
# Using atproto client
|
||||
atproto put-record \
|
||||
--collection io.atcr.hold.crew \
|
||||
--rkey "company-did:plc:engineer1" \
|
||||
--value '{
|
||||
"$type": "io.atcr.hold.crew",
|
||||
"hold": "at://did:plc:admin/io.atcr.hold/company",
|
||||
"member": "did:plc:engineer1",
|
||||
"role": "write"
|
||||
}'
|
||||
```
|
||||
# 3. Admin adds team members via hold's PDS (requires OAuth)
|
||||
# (TODO: Implement crew management UI/CLI)
|
||||
|
||||
4. **Team members set their profile** to use the shared hold:
|
||||
```bash
|
||||
# Engineer updates their sailor profile
|
||||
atproto put-record \
|
||||
--collection io.atcr.sailor.profile \
|
||||
--rkey "self" \
|
||||
--value '{
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "https://company-hold.fly.dev"
|
||||
}'
|
||||
```
|
||||
# 4. Team members set their sailor profile:
|
||||
atproto put-record \
|
||||
--collection io.atcr.sailor.profile \
|
||||
--rkey "self" \
|
||||
--value '{
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "did:web:team-hold.fly.dev"
|
||||
}'
|
||||
|
||||
5. **Hold service queries PDS** for crew records to authorize writes
|
||||
6. **Engineers push/pull** using `atcr.io/engineer1/myapp` - blobs go to company hold
|
||||
# 5. Team members can now push/pull using team hold
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **No resume/partial uploads** - Storage service doesn't track upload state
|
||||
2. **No advanced features** - Just basic put/get, no deduplication logic
|
||||
3. **In-memory cache** - Hold endpoint cache is in-memory (for production, use Redis)
|
||||
4. **Manual profile updates** - No UI for updating sailor profile (must use ATProto client)
|
||||
### Current IAM Challenges
|
||||
|
||||
## Performance Optimization: S3 Presigned URLs
|
||||
See [EMBEDDED_PDS.md](./EMBEDDED_PDS.md#iam-challenges) for detailed discussion.
|
||||
|
||||
**Status:** Planned implementation (see [PRESIGNED_URLS.md](./PRESIGNED_URLS.md))
|
||||
**Known issues:**
|
||||
1. **RPC permission format**: Service tokens don't work with IP-based DIDs in local dev
|
||||
2. **Dynamic hold discovery**: AppView can't dynamically OAuth arbitrary holds from sailor profiles
|
||||
3. **Manual profile management**: No UI for updating sailor profile (must use ATProto client)
|
||||
|
||||
Currently, hold services act as proxies for blob data. With presigned URLs:
|
||||
|
||||
- **Downloads:** Docker → S3 direct (via 307 redirect)
|
||||
- **Uploads:** Docker → AppView → S3 (via presigned URL)
|
||||
- **Hold service bandwidth:** Reduced by 99.98% (only orchestration)
|
||||
|
||||
**Benefits:**
|
||||
- Hold services can run on minimal infrastructure ($5/month instances)
|
||||
- Direct S3 transfers at maximum speed
|
||||
- Scales to arbitrarily large images
|
||||
- Works with Storj, MinIO, Backblaze B2, Cloudflare R2
|
||||
|
||||
See [PRESIGNED_URLS.md](./PRESIGNED_URLS.md) for complete technical details and implementation guide.
|
||||
**Workaround:** Use hostname-based DIDs (`did:web:hold.example.com`) and public holds for now.
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **S3 Presigned URLs** - Implement direct S3 URLs (see [PRESIGNED_URLS.md](./PRESIGNED_URLS.md))
|
||||
2. **Automatic failover** - Multiple storage endpoints, fallback to default
|
||||
3. **Storage analytics** - Track usage per DID
|
||||
4. **Quota integration** - Optional quota tracking in storage service
|
||||
5. **Profile management UI** - Web interface for users to manage their sailor profile
|
||||
6. **Distributed cache** - Redis/Memcached for hold endpoint cache in multi-instance deployments
|
||||
|
||||
## Comparison to Default Storage
|
||||
|
||||
| Feature | Default (Shared S3) | BYOS |
|
||||
|---------|---------------------|------|
|
||||
| Setup | None required | Deploy storage service |
|
||||
| Cost | Free (with quota) | User pays for S3/Storj |
|
||||
| Control | Limited | Full control |
|
||||
| Performance | Shared | Dedicated |
|
||||
| Quotas | Enforced by AppView | User managed |
|
||||
| Privacy | Blobs in shared bucket | Blobs in user's bucket |
|
||||
1. **Crew management UI** - Web interface for adding/removing crew members
|
||||
2. **Dynamic OAuth** - Support for arbitrary BYOS holds without pre-configuration
|
||||
3. **Hold migration** - Tools for moving blobs between holds
|
||||
4. **Storage analytics** - Track usage per user/repository
|
||||
5. **Distributed cache** - Redis for hold DID cache in multi-instance deployments
|
||||
|
||||
## References
|
||||
|
||||
- [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Embedded PDS architecture and IAM details
|
||||
- [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
|
||||
- [Distribution Storage Drivers](https://distribution.github.io/distribution/storage-drivers/)
|
||||
- [S3 Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html)
|
||||
- [Storj Documentation](https://docs.storj.io/)
|
||||
|
||||
+174
-1155
File diff suppressed because it is too large
Load Diff
+236
-815
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,733 @@
|
||||
# Image Signing with ATProto
|
||||
|
||||
ATCR can support cryptographic signing of container images to ensure authenticity and integrity. This document explores different approaches and recommends a design based on Notary v2's plugin architecture adapted for ATProto.
|
||||
|
||||
## Background: Why Not Cosign?
|
||||
|
||||
[Sigstore Cosign](https://github.com/sigstore/cosign) is the most popular OCI image signing tool, but has several incompatibilities with ATProto:
|
||||
|
||||
### 1. Key Format Mismatch
|
||||
|
||||
**ATProto PDS keys:**
|
||||
- Format: secp256k1 (K256) for signing
|
||||
- Purpose: ATProto record signatures, DID authentication
|
||||
- Access: Private keys never leave the PDS server
|
||||
- Standard: ATProto specification
|
||||
|
||||
**Cosign expected keys:**
|
||||
- Format: ECDSA P-256, RSA, or Ed25519
|
||||
- Purpose: Image signing (not ATProto records)
|
||||
- Access: User-controlled private keys
|
||||
- Standard: Sigstore/PKIX
|
||||
|
||||
**Problem:** Can't use PDS keys directly for Cosign signing - wrong curve, wrong access model, wrong security boundary.
|
||||
|
||||
### 2. No Direct PDS Key Access
|
||||
|
||||
**Security model:**
|
||||
- PDS private keys are server-side secrets
|
||||
- Never exposed to clients (even authenticated users)
|
||||
- Used only by PDS for ATProto operations
|
||||
- Exposing them would compromise entire account security
|
||||
|
||||
**Cosign requirement:**
|
||||
- Needs access to private key for signing operations
|
||||
- Expects user-controlled keys or KMS integration
|
||||
|
||||
**Problem:** Can't sign images client-side with PDS keys without fundamentally breaking ATProto security model.
|
||||
|
||||
### 3. Keyless Signing Complexity
|
||||
|
||||
Cosign supports "keyless" signing via OIDC + Fulcio CA:
|
||||
|
||||
**What it requires:**
|
||||
- OIDC identity provider (Google, GitHub, etc.)
|
||||
- Fulcio certificate authority (issues short-lived certs)
|
||||
- Rekor transparency log (immutable signature log)
|
||||
- All infrastructure managed by Sigstore
|
||||
|
||||
**ATProto adaptation would need:**
|
||||
- **OIDC bridge**: Make ATProto DIDs look like OIDC identities
|
||||
- Map `did:plc:alice123` → OIDC claims
|
||||
- PDS as OIDC provider? (not in spec)
|
||||
- Requires custom OIDC server wrapping ATProto auth
|
||||
- **Fulcio adaptation**: Issue certs based on ATProto identities
|
||||
- Deploy and manage CA infrastructure
|
||||
- Handle DID resolution in cert issuance
|
||||
- Trust anchor distribution
|
||||
- **Rekor instance**: Public transparency log for signatures
|
||||
- High availability requirements
|
||||
- Storage and indexing at scale
|
||||
- Replication and backup
|
||||
|
||||
**Problem:** Too much infrastructure for ATCR to host and manage. Defeats the purpose of decentralized architecture.
|
||||
|
||||
### 4. Signature Storage
|
||||
|
||||
**Cosign storage:**
|
||||
- OCI registry artifacts (signatures as ORAS manifests)
|
||||
- Stored alongside images in registry
|
||||
|
||||
**ATCR ideal:**
|
||||
- Signatures in ATProto records (user's PDS)
|
||||
- Discoverable via ATProto queries
|
||||
- Integrated with ATProto's existing signature/verification model
|
||||
|
||||
**Problem:** Would need to patch Cosign or run dual storage (OCI + ATProto) which creates consistency issues.
|
||||
|
||||
### Conclusion: Cosign Doesn't Fit
|
||||
|
||||
While Cosign is excellent for traditional registries, forcing it into ATProto would require:
|
||||
- Breaking ATProto security model (exposing PDS keys), OR
|
||||
- Building massive OIDC/Fulcio/Rekor infrastructure, OR
|
||||
- Running parallel storage systems with consistency problems
|
||||
|
||||
**Better approach:** Use a more flexible signing framework designed for extensibility.
|
||||
|
||||
## Notary v2: Plugin-Based Architecture
|
||||
|
||||
[Notary v2](https://notaryproject.dev/) (also called "Notation" or "Notary Project") is a CNCF signature specification with a plugin architecture that fits ATProto better.
|
||||
|
||||
### Why Notary v2?
|
||||
|
||||
**Flexible plugin system:**
|
||||
- **Trust store plugins**: Custom key resolution (e.g., from ATProto records)
|
||||
- **Signature plugins**: Custom signature storage (e.g., in PDS)
|
||||
- **Verification plugins**: Custom verification logic
|
||||
- Plugins written in any language, communicate via stdio
|
||||
|
||||
**Multiple key types supported:**
|
||||
- ECDSA, RSA, Ed25519 out of box
|
||||
- Can support custom key types via plugins
|
||||
- Signature envelope format is extensible
|
||||
|
||||
**Designed for extensibility:**
|
||||
- Not tied to specific PKI (unlike Cosign/Sigstore)
|
||||
- Trust policies are configurable
|
||||
- Storage backend is pluggable
|
||||
- Works with custom identity systems
|
||||
|
||||
**Standard CLI:**
|
||||
- `notation sign` / `notation verify` commands
|
||||
- Users don't need to learn new tools
|
||||
- Integration with Docker/containerd
|
||||
|
||||
### Notary v2 Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ notation CLI │ User signs/verifies images
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
├─────────────────────────────────────┐
|
||||
│ │
|
||||
┌──────────▼─────────┐ ┌───────────▼──────────┐
|
||||
│ Signing Plugin │ │ Trust Store Plugin │
|
||||
│ │ │ │
|
||||
│ - Read private key │ │ - Resolve DID → PDS │
|
||||
│ - Generate sig │ │ - Fetch public keys │
|
||||
│ - Store in PDS │ │ - Verify trust │
|
||||
└────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ User's PDS (ATProto) │
|
||||
│ │
|
||||
│ io.atcr.signing.key (public keys) │
|
||||
│ io.atcr.signature (signatures) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Proposed Design: ATProto Signing
|
||||
|
||||
### Key Management
|
||||
|
||||
**Separate signing keys from PDS keys:**
|
||||
|
||||
1. **User generates signing key pair locally:**
|
||||
```bash
|
||||
notation key generate --id alice-signing-key --type ecdsa
|
||||
# Or: --type ed25519, --type rsa
|
||||
```
|
||||
|
||||
2. **Public key published to ATProto:**
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.signing.key",
|
||||
"keyId": "alice-signing-key",
|
||||
"keyType": "ecdsa-p256",
|
||||
"publicKey": "-----BEGIN PUBLIC KEY-----\nMFkw...",
|
||||
"validFrom": "2025-10-20T12:00:00Z",
|
||||
"expiresAt": "2026-10-20T12:00:00Z",
|
||||
"revoked": false,
|
||||
"createdAt": "2025-10-20T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Private key stored locally:**
|
||||
- Docker credential store
|
||||
- OS keychain (macOS Keychain, Windows Credential Manager)
|
||||
- File with restrictive permissions
|
||||
- Hardware security module (future)
|
||||
|
||||
**Why separate keys?**
|
||||
- ✅ No need to access PDS private keys
|
||||
- ✅ Standard key formats (ECDSA, Ed25519, RSA)
|
||||
- ✅ User controls key lifecycle
|
||||
- ✅ Can use hardware tokens (YubiKey, etc.)
|
||||
- ✅ Security boundary separation (signing ≠ identity)
|
||||
- ✅ Key rotation without changing DID
|
||||
|
||||
### Signing Flow
|
||||
|
||||
```
|
||||
1. User: notation sign atcr.io/alice/myapp:latest --key alice-signing-key
|
||||
|
||||
2. notation-atproto plugin:
|
||||
a. Resolve image → manifest digest
|
||||
b. Read private key from local keystore
|
||||
c. Generate signature over manifest digest
|
||||
d. Get OAuth token for alice's PDS
|
||||
e. Create signature record in alice's PDS
|
||||
|
||||
3. Signature stored in alice's PDS:
|
||||
{
|
||||
"$type": "io.atcr.signature",
|
||||
"repository": "alice/myapp",
|
||||
"digest": "sha256:abc123...",
|
||||
"signature": "MEUCIQDx...", // base64 signature bytes
|
||||
"keyId": "alice-signing-key",
|
||||
"signatureAlgorithm": "ecdsa-p256-sha256",
|
||||
"signedAt": "2025-10-20T12:34:56Z"
|
||||
}
|
||||
|
||||
4. Record key: sha256 of (digest + keyId) for deduplication
|
||||
```
|
||||
|
||||
### Verification Flow
|
||||
|
||||
```
|
||||
1. User: notation verify atcr.io/alice/myapp:latest
|
||||
|
||||
2. notation-atproto plugin:
|
||||
a. Resolve "alice" → did:plc:alice123 → pds.alice.com
|
||||
b. Fetch manifest digest: sha256:abc123
|
||||
c. Query alice's PDS for signatures:
|
||||
GET /xrpc/com.atproto.repo.listRecords?
|
||||
repo=did:plc:alice123&
|
||||
collection=io.atcr.signature
|
||||
d. Filter records matching digest: sha256:abc123
|
||||
e. For each signature:
|
||||
- Fetch public key from io.atcr.signing.key record
|
||||
- Check key not revoked, not expired
|
||||
- Verify signature bytes over digest
|
||||
- Check trust policy (is this key trusted?)
|
||||
|
||||
3. Trust policy evaluation:
|
||||
- Signature valid cryptographically? ✓
|
||||
- Key belongs to image owner (alice)? ✓
|
||||
- Key not revoked? ✓
|
||||
- Key not expired? ✓
|
||||
- Trust policy satisfied? ✓
|
||||
|
||||
4. Output: Verification succeeded ✓
|
||||
```
|
||||
|
||||
### Trust Policies
|
||||
|
||||
Notary v2 uses trust policies to define what signatures are required:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"trustPolicies": [
|
||||
{
|
||||
"name": "atcr-images",
|
||||
"registryScopes": ["atcr.io/*/*"],
|
||||
"signatureVerification": {
|
||||
"level": "strict"
|
||||
},
|
||||
"trustStores": ["atproto:default"],
|
||||
"trustedIdentities": [
|
||||
"did:plc:*" // Trust any ATProto DID
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Policy options:**
|
||||
- `level: strict` - Signature required, verification must pass
|
||||
- `level: permissive` - Signature optional, but verified if present
|
||||
- `level: audit` - Signature logged but doesn't block
|
||||
- `level: skip` - No verification
|
||||
|
||||
**Trust store resolution:**
|
||||
- `atproto:default` - Use ATProto plugin to resolve keys
|
||||
- Plugin queries user's PDS for `io.atcr.signing.key` records
|
||||
- Verifies key is owned by the image owner (DID match)
|
||||
|
||||
### ATProto Records
|
||||
|
||||
**io.atcr.signing.key** - Public signing keys
|
||||
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.signing.key",
|
||||
"keyId": "alice-signing-key",
|
||||
"keyType": "ecdsa-p256",
|
||||
"publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZI...",
|
||||
"validFrom": "2025-10-20T12:00:00Z",
|
||||
"expiresAt": "2026-10-20T12:00:00Z",
|
||||
"revoked": false,
|
||||
"purpose": ["image-signing"],
|
||||
"createdAt": "2025-10-20T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Record key:** `keyId` (user-chosen identifier)
|
||||
|
||||
**Fields:**
|
||||
- `keyId`: Unique identifier for this key
|
||||
- `keyType`: Algorithm (ecdsa-p256, ed25519, rsa-2048, rsa-4096)
|
||||
- `publicKey`: PEM-encoded public key
|
||||
- `validFrom`: Key becomes valid at this time
|
||||
- `expiresAt`: Key expires at this time (null = no expiry)
|
||||
- `revoked`: Key has been revoked (true/false)
|
||||
- `purpose`: Array of purposes (image-signing, sbom-signing, etc.)
|
||||
|
||||
**io.atcr.signature** - Image signatures
|
||||
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.signature",
|
||||
"repository": "alice/myapp",
|
||||
"digest": "sha256:abc123...",
|
||||
"signature": "MEUCIQDxH7...",
|
||||
"keyId": "alice-signing-key",
|
||||
"signatureAlgorithm": "ecdsa-p256-sha256",
|
||||
"signedAt": "2025-10-20T12:34:56Z",
|
||||
"createdAt": "2025-10-20T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Record key:** SHA256 hash of `(digest || keyId)` for deduplication
|
||||
|
||||
**Fields:**
|
||||
- `repository`: Image repository (alice/myapp)
|
||||
- `digest`: Manifest digest being signed
|
||||
- `signature`: Base64-encoded signature bytes
|
||||
- `keyId`: Reference to signing key record
|
||||
- `signatureAlgorithm`: Algorithm used for signing
|
||||
- `signedAt`: When signature was created
|
||||
|
||||
### Plugin Implementation
|
||||
|
||||
**notation-atproto** - Notary v2 plugin for ATProto
|
||||
|
||||
**Trust store plugin:**
|
||||
```go
|
||||
// Implements: notation trust store plugin spec
|
||||
// https://notaryproject.dev/docs/user-guides/how-to/plugin-management/
|
||||
|
||||
type ATProtoTrustStore struct {
|
||||
resolver *atproto.Resolver
|
||||
client *atproto.Client
|
||||
}
|
||||
|
||||
// GetKeys resolves public keys for a given identity (DID)
|
||||
func (t *ATProtoTrustStore) GetKeys(did string) ([]PublicKey, error) {
|
||||
// 1. Resolve DID → PDS endpoint
|
||||
pds, err := t.resolver.ResolvePDS(did)
|
||||
|
||||
// 2. Query PDS for io.atcr.signing.key records
|
||||
records, err := t.client.ListRecords(pds, did, "io.atcr.signing.key")
|
||||
|
||||
// 3. Filter active keys (not revoked, not expired)
|
||||
keys := []PublicKey{}
|
||||
for _, record := range records {
|
||||
if !record.Revoked && !record.Expired() {
|
||||
keys = append(keys, ParsePublicKey(record.PublicKey))
|
||||
}
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Signature store plugin:**
|
||||
```go
|
||||
// Store signature in user's PDS
|
||||
func (s *ATProtoSignatureStore) StoreSignature(sig Signature) error {
|
||||
// 1. Get OAuth token for user's PDS
|
||||
token, err := s.oauthClient.GetToken()
|
||||
|
||||
// 2. Create signature record
|
||||
record := SignatureRecord{
|
||||
Type: "io.atcr.signature",
|
||||
Repository: sig.Repository,
|
||||
Digest: sig.Digest,
|
||||
Signature: base64.Encode(sig.Bytes),
|
||||
KeyId: sig.KeyId,
|
||||
SignatureAlgorithm: sig.Algorithm,
|
||||
SignedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 3. Generate record key (hash of digest + keyId)
|
||||
rkey := sha256.Sum256([]byte(sig.Digest + sig.KeyId))
|
||||
|
||||
// 4. Write to PDS
|
||||
err = s.client.PutRecord(pds, did, "io.atcr.signature", hex.Encode(rkey), record)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Retrieve signatures for a digest
|
||||
func (s *ATProtoSignatureStore) GetSignatures(did, digest string) ([]Signature, error) {
|
||||
// Query PDS for matching signatures
|
||||
records, err := s.client.ListRecords(pds, did, "io.atcr.signature")
|
||||
|
||||
// Filter by digest
|
||||
sigs := []Signature{}
|
||||
for _, record := range records {
|
||||
if record.Digest == digest {
|
||||
sigs = append(sigs, ParseSignature(record))
|
||||
}
|
||||
}
|
||||
|
||||
return sigs, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Plugin installation:**
|
||||
```bash
|
||||
# Install notation CLI
|
||||
brew install notation
|
||||
|
||||
# Install ATProto plugin
|
||||
notation plugin install notation-atproto --version v1.0.0
|
||||
|
||||
# Configure trust policy
|
||||
cat > ~/.config/notation/trustpolicy.json <<EOF
|
||||
{
|
||||
"version": "1.0",
|
||||
"trustPolicies": [
|
||||
{
|
||||
"name": "atcr-images",
|
||||
"registryScopes": ["atcr.io/*/*"],
|
||||
"signatureVerification": {"level": "strict"},
|
||||
"trustStores": ["atproto:default"],
|
||||
"trustedIdentities": ["did:plc:*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
## User Workflows
|
||||
|
||||
### Initial Setup
|
||||
|
||||
```bash
|
||||
# 1. Generate signing key pair
|
||||
notation key generate --id alice-signing-key --type ecdsa
|
||||
|
||||
# Private key stored in: ~/.config/notation/keys/
|
||||
# Public key extracted by plugin
|
||||
|
||||
# 2. Publish public key to PDS
|
||||
notation-atproto key publish alice-signing-key
|
||||
|
||||
# Plugin uploads io.atcr.signing.key record to alice's PDS
|
||||
# Requires OAuth authentication to alice's PDS
|
||||
|
||||
# 3. Verify key is published
|
||||
notation-atproto key list
|
||||
|
||||
# Output:
|
||||
# alice-signing-key (ecdsa-p256) - Active
|
||||
# Published: 2025-10-20T12:00:00Z
|
||||
# Expires: 2026-10-20T12:00:00Z
|
||||
# DID: did:plc:alice123
|
||||
```
|
||||
|
||||
### Signing Images
|
||||
|
||||
```bash
|
||||
# Sign an image after pushing
|
||||
docker push atcr.io/alice/myapp:latest
|
||||
|
||||
notation sign atcr.io/alice/myapp:latest \
|
||||
--key alice-signing-key \
|
||||
--plugin atproto
|
||||
|
||||
# Plugin:
|
||||
# 1. Reads private key from ~/.config/notation/keys/
|
||||
# 2. Signs manifest digest
|
||||
# 3. Uploads signature to alice's PDS (io.atcr.signature record)
|
||||
# 4. Returns success
|
||||
|
||||
# Output:
|
||||
# Successfully signed atcr.io/alice/myapp:latest
|
||||
# Signature stored in PDS: did:plc:alice123
|
||||
```
|
||||
|
||||
### Verifying Images
|
||||
|
||||
```bash
|
||||
# Verify before running
|
||||
notation verify atcr.io/alice/myapp:latest
|
||||
|
||||
# Plugin:
|
||||
# 1. Resolves "alice" → did:plc:alice123 → pds.alice.com
|
||||
# 2. Fetches manifest digest
|
||||
# 3. Queries alice's PDS for signatures
|
||||
# 4. Fetches public key from io.atcr.signing.key
|
||||
# 5. Verifies signature cryptographically
|
||||
# 6. Checks trust policy
|
||||
|
||||
# Output:
|
||||
# ✓ Signature verification succeeded
|
||||
#
|
||||
# Signed by: did:plc:alice123
|
||||
# Key ID: alice-signing-key
|
||||
# Signed at: 2025-10-20T12:34:56Z
|
||||
# Algorithm: ecdsa-p256-sha256
|
||||
```
|
||||
|
||||
### Key Rotation
|
||||
|
||||
```bash
|
||||
# Generate new key
|
||||
notation key generate --id alice-signing-key-2 --type ecdsa
|
||||
|
||||
# Publish new key
|
||||
notation-atproto key publish alice-signing-key-2
|
||||
|
||||
# Re-sign images with new key
|
||||
notation sign atcr.io/alice/myapp:latest --key alice-signing-key-2
|
||||
|
||||
# Revoke old key
|
||||
notation-atproto key revoke alice-signing-key
|
||||
|
||||
# Plugin updates io.atcr.signing.key record:
|
||||
# { ..., "revoked": true, "revokedAt": "2025-11-01T..." }
|
||||
|
||||
# Old signatures still exist but verification will fail
|
||||
# (revoked key = untrusted)
|
||||
```
|
||||
|
||||
### Key Expiration
|
||||
|
||||
```bash
|
||||
# Generate key with expiration
|
||||
notation key generate \
|
||||
--id alice-signing-key \
|
||||
--type ecdsa \
|
||||
--expires 365d # 1 year
|
||||
|
||||
# Publish with expiration
|
||||
notation-atproto key publish alice-signing-key
|
||||
|
||||
# PDS record:
|
||||
# {
|
||||
# "validFrom": "2025-10-20T12:00:00Z",
|
||||
# "expiresAt": "2026-10-20T12:00:00Z"
|
||||
# }
|
||||
|
||||
# After expiration, verification fails:
|
||||
notation verify atcr.io/alice/myapp:latest
|
||||
# ✗ Signature verification failed
|
||||
# Signing key expired on 2026-10-20T12:00:00Z
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Key Storage
|
||||
|
||||
**Private keys must be protected:**
|
||||
- File permissions: `0600` (owner read/write only)
|
||||
- Use OS keychain when possible (macOS Keychain, Windows Credential Manager)
|
||||
- Consider hardware tokens (YubiKey, TPM) for production
|
||||
- Never commit private keys to git
|
||||
|
||||
**Public keys are public:**
|
||||
- Stored in user's PDS (publicly readable)
|
||||
- Anyone can verify signatures
|
||||
- Revocation is public and immediate
|
||||
|
||||
### Trust Model
|
||||
|
||||
**What signatures prove:**
|
||||
- ✅ Image manifest hasn't been tampered with since signing
|
||||
- ✅ Signer had access to private key at signing time
|
||||
- ✅ Signer's DID matches image owner (alice signed alice/myapp)
|
||||
|
||||
**What signatures don't prove:**
|
||||
- ❌ Image is free of vulnerabilities
|
||||
- ❌ Image contents are safe to run
|
||||
- ❌ Signer's identity is verified (depends on DID trust)
|
||||
|
||||
**Trust anchors:**
|
||||
- Trust PDS to correctly serve signing key records
|
||||
- Trust DID resolution (PLC directory, did:web DNS)
|
||||
- Trust signature algorithms (ECDSA, Ed25519, RSA)
|
||||
- Trust user to protect their private keys
|
||||
|
||||
### Key Compromise
|
||||
|
||||
If a private signing key is compromised:
|
||||
|
||||
```bash
|
||||
# 1. Immediately revoke the key
|
||||
notation-atproto key revoke alice-signing-key --reason "Key compromised"
|
||||
|
||||
# 2. Generate new key
|
||||
notation key generate --id alice-signing-key-new --type ecdsa
|
||||
|
||||
# 3. Publish new key
|
||||
notation-atproto key publish alice-signing-key-new
|
||||
|
||||
# 4. Re-sign all images with new key
|
||||
for image in $(docker images --format "{{.Repository}}:{{.Tag}}"); do
|
||||
notation sign $image --key alice-signing-key-new
|
||||
done
|
||||
|
||||
# 5. Alert users to only trust new key
|
||||
# (Old signatures will fail verification due to revocation)
|
||||
```
|
||||
|
||||
**Revocation is immediate:**
|
||||
- PDS record updated with `"revoked": true`
|
||||
- All verification attempts fail instantly
|
||||
- No need to update certificate revocation lists (CRLs)
|
||||
- ATProto record queries are always fresh
|
||||
|
||||
### Multiple Signatures
|
||||
|
||||
Images can have multiple signatures:
|
||||
|
||||
```bash
|
||||
# Alice signs with her key
|
||||
notation sign atcr.io/alice/myapp:latest --key alice-signing-key
|
||||
|
||||
# CI/CD system signs with separate key
|
||||
notation sign atcr.io/alice/myapp:latest --key ci-signing-key
|
||||
|
||||
# Both signatures stored in alice's PDS
|
||||
# Verification requires both (configurable in trust policy)
|
||||
```
|
||||
|
||||
**Trust policy:**
|
||||
```json
|
||||
{
|
||||
"trustPolicies": [{
|
||||
"name": "require-dual-signature",
|
||||
"registryScopes": ["atcr.io/alice/*"],
|
||||
"signatureVerification": {
|
||||
"level": "strict",
|
||||
"verifyTimestamp": true,
|
||||
"override": {
|
||||
"all": ["alice-signing-key", "ci-signing-key"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Core Plugin (4-6 weeks)
|
||||
|
||||
**Week 1-2: Trust store plugin**
|
||||
- Implement DID resolution
|
||||
- Query `io.atcr.signing.key` records
|
||||
- Parse and validate public keys
|
||||
- Handle revocation and expiration
|
||||
|
||||
**Week 3-4: Signature store plugin**
|
||||
- OAuth integration for PDS writes
|
||||
- Create `io.atcr.signature` records
|
||||
- Query signatures for verification
|
||||
- Handle record key generation
|
||||
|
||||
**Week 5-6: Integration testing**
|
||||
- End-to-end sign/verify workflows
|
||||
- Key rotation scenarios
|
||||
- Revocation handling
|
||||
- Multi-signature support
|
||||
|
||||
### Phase 2: Tooling (2-3 weeks)
|
||||
|
||||
**CLI commands:**
|
||||
```bash
|
||||
notation-atproto key generate
|
||||
notation-atproto key publish
|
||||
notation-atproto key list
|
||||
notation-atproto key revoke
|
||||
notation-atproto signature list <image>
|
||||
notation-atproto signature inspect <image>
|
||||
```
|
||||
|
||||
**Helper utilities:**
|
||||
- Bulk re-signing for key rotation
|
||||
- Signature audit logs
|
||||
- Trust policy generators
|
||||
- Key lifecycle management
|
||||
|
||||
### Phase 3: AppView Integration (2-3 weeks)
|
||||
|
||||
**Web UI features:**
|
||||
- Display signature status on repository pages
|
||||
- Show signing keys for users
|
||||
- Signature verification badges
|
||||
- Key management interface
|
||||
|
||||
**API endpoints:**
|
||||
- `GET /v2/alice/myapp/signatures` - List signatures for image
|
||||
- `GET /v2/alice/keys` - List user's signing keys
|
||||
- `POST /v2/alice/keys/revoke` - Revoke key via web UI
|
||||
|
||||
### Phase 4: Advanced Features (ongoing)
|
||||
|
||||
**Hardware token support:**
|
||||
- YubiKey integration
|
||||
- TPM-backed keys
|
||||
- Hardware-backed keystores
|
||||
|
||||
**Timestamp verification:**
|
||||
- Trusted timestamp authorities
|
||||
- Prove signature was created at specific time
|
||||
- Long-term signature validity
|
||||
|
||||
**SBOM signing:**
|
||||
- Sign SBOMs with same keys
|
||||
- Link SBOM signatures to image signatures
|
||||
- Unified verification workflow
|
||||
|
||||
## Comparison: Cosign vs Notary v2 for ATCR
|
||||
|
||||
| Feature | Cosign | Notary v2 | Winner |
|
||||
|---------|--------|-----------|--------|
|
||||
| **ATProto integration** | Requires OIDC bridge | Plugin system | ✅ Notary |
|
||||
| **Key format flexibility** | Limited | Extensible | ✅ Notary |
|
||||
| **Custom storage** | OCI only | Pluggable | ✅ Notary |
|
||||
| **Infrastructure needs** | Fulcio + Rekor | None | ✅ Notary |
|
||||
| **Keyless signing** | Yes (complex) | No | ⚠️ Cosign* |
|
||||
| **Ecosystem maturity** | High | Medium | ⚠️ Cosign* |
|
||||
| **CLI simplicity** | Very simple | Simple | ⚠️ Cosign* |
|
||||
| **Plugin development** | N/A | Required | ⚠️ Mixed |
|
||||
|
||||
*Cosign advantages don't outweigh ATProto incompatibilities
|
||||
|
||||
**Recommendation: Notary v2 with ATProto plugin**
|
||||
|
||||
## References
|
||||
|
||||
- [Notary v2 Specification](https://notaryproject.dev/)
|
||||
- [Notation CLI](https://github.com/notaryproject/notation)
|
||||
- [Notary Plugin Specification](https://notaryproject.dev/docs/user-guides/how-to/plugin-management/)
|
||||
- [Sigstore Cosign](https://github.com/sigstore/cosign) (for comparison)
|
||||
- [ATProto Specification](https://atproto.com/)
|
||||
- [OCI Artifacts](https://github.com/opencontainers/artifacts)
|
||||
- [RFC 7515 - JSON Web Signature](https://datatracker.ietf.org/doc/html/rfc7515) (signature formats)
|
||||
+365
-72
@@ -1,101 +1,394 @@
|
||||
User approved Claude's plan:
|
||||
⎿ Implement Sailor Profile & Hold Discovery System
|
||||
# Sailor Profile System
|
||||
|
||||
Summary
|
||||
## Overview
|
||||
|
||||
Add io.atcr.sailor.profile record type to manage user's default hold preference, and update manifest to store historical hold endpoint reference. This enables transparent hold
|
||||
routing while preserving image ownership semantics.
|
||||
The sailor profile system allows users to choose which hold (storage service) to use for their container images. This enables:
|
||||
- **Personal holds** - Use your own S3/Storj/Minio storage
|
||||
- **Shared holds** - Join a team or community hold
|
||||
- **Default holds** - Use AppView's default storage (free tier)
|
||||
- **Transparent infrastructure** - Hold choice doesn't affect image URL
|
||||
|
||||
Changes Required
|
||||
## Concepts
|
||||
|
||||
1. Create Sailor Profile Lexicon
|
||||
**Sailor Profile** (`io.atcr.sailor.profile`):
|
||||
- Record stored in user's PDS
|
||||
- Contains `defaultHold` preference (DID or URL)
|
||||
- Created automatically on first authentication
|
||||
- Managed via web UI or ATProto client
|
||||
|
||||
File: lexicons/io/atcr/sailor/profile.json
|
||||
- New record type: io.atcr.sailor.profile
|
||||
- Fields: defaultHold (string, nullable), createdAt, updatedAt
|
||||
**Hold Discovery Priority**:
|
||||
1. User's sailor profile `defaultHold` (if set)
|
||||
2. User's own hold records (`io.atcr.hold`) - legacy
|
||||
3. AppView's `default_hold_did` configuration
|
||||
|
||||
2. Update Manifest Lexicon
|
||||
## Sailor Profile Record
|
||||
|
||||
File: lexicons/io/atcr/manifest.json
|
||||
- Add holdEndpoint field (string, required)
|
||||
- This is historical reference (immutable per manifest)
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "did:web:hold.example.com",
|
||||
"createdAt": "2025-10-02T12:00:00Z",
|
||||
"updatedAt": "2025-10-02T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
3. Update Go Types
|
||||
**Fields:**
|
||||
- `defaultHold` (string, optional) - Hold DID or URL (auto-normalized to DID)
|
||||
- `createdAt` (datetime, required) - Profile creation timestamp
|
||||
- `updatedAt` (datetime, required) - Last update timestamp
|
||||
|
||||
File: pkg/atproto/lexicon.go
|
||||
- Add SailorProfileCollection = "io.atcr.sailor.profile"
|
||||
- Add SailorProfileRecord struct
|
||||
- Add NewSailorProfileRecord() constructor
|
||||
- Update ManifestRecord struct to include HoldEndpoint field
|
||||
**Record key:** Always `"self"` (only one profile per user)
|
||||
|
||||
4. Create Profile Management
|
||||
**Collection:** `io.atcr.sailor.profile`
|
||||
|
||||
File: pkg/atproto/profile.go (new file)
|
||||
- EnsureProfile(ctx, client, defaultHoldDID) function
|
||||
- Logic: check if profile exists, create with default if not
|
||||
## Profile Management
|
||||
|
||||
5. Update Auth Handlers
|
||||
### Automatic Creation
|
||||
|
||||
Files: pkg/auth/exchange/handler.go and pkg/auth/token/service.go
|
||||
- Call EnsureProfile() after token validation
|
||||
- Use authenticated client (has write access to user's PDS)
|
||||
- Pass AppView's default_hold_did config (format: "did:web:hold01.atcr.io")
|
||||
Profiles are created automatically on first authentication:
|
||||
|
||||
6. Update Hold Resolution
|
||||
```go
|
||||
// During OAuth login or Basic Auth token exchange
|
||||
func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
// ... OAuth flow ...
|
||||
|
||||
File: pkg/middleware/registry.go
|
||||
- Update findStorageEndpoint() priority:
|
||||
a. Check io.atcr.sailor.profile.defaultHold
|
||||
b. If null (opted out): check user's io.atcr.hold, then AppView default
|
||||
c. If no profile: check user's io.atcr.hold, then AppView default
|
||||
// Create ATProto client with user's OAuth session
|
||||
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
|
||||
|
||||
7. Store Hold in Manifest
|
||||
// Ensure profile exists (creates with AppView's default if not)
|
||||
err := atproto.EnsureProfile(ctx, client, appViewDefaultHoldDID)
|
||||
}
|
||||
```
|
||||
|
||||
File: pkg/atproto/manifest_store.go
|
||||
- When creating manifest, include resolved holdEndpoint
|
||||
- Pass hold endpoint through context or parameter
|
||||
**Behavior:**
|
||||
- If profile exists → no-op
|
||||
- If profile doesn't exist → creates with `defaultHold` set to AppView's default
|
||||
- If AppView has no default configured → creates with empty `defaultHold`
|
||||
|
||||
8. Update Pull to Use Manifest Hold
|
||||
### Web UI Management
|
||||
|
||||
File: pkg/atproto/manifest_store.go and pkg/storage/routing_repository.go
|
||||
- On pull, extract holdEndpoint from manifest
|
||||
- Route blob requests to that hold (not via discovery)
|
||||
Users can update their profile via the settings page (`/settings`):
|
||||
|
||||
9. Update Documentation
|
||||
**View current profile:**
|
||||
```
|
||||
GET /settings
|
||||
→ Shows current defaultHold value
|
||||
```
|
||||
|
||||
Files: CLAUDE.md, docs/BYOS.md, .env.example
|
||||
- Document sailor profile concept
|
||||
- Explain hold resolution priority
|
||||
- Update examples for shared holds
|
||||
- Document how crew members configure profile
|
||||
**Update defaultHold:**
|
||||
```
|
||||
POST /api/settings/update-hold
|
||||
Form data: hold_endpoint=did:web:team-hold.fly.dev
|
||||
|
||||
Benefits
|
||||
→ Updates sailor profile in user's PDS
|
||||
→ Returns success confirmation
|
||||
```
|
||||
|
||||
- ✅ URL structure remains atcr.io/<owner>/<image> (ownership clear)
|
||||
- ✅ Hold is transparent infrastructure (like S3 region)
|
||||
- ✅ Supports personal, shared, and public holds
|
||||
- ✅ Historical reference in manifest (pull works even if defaults change)
|
||||
- ✅ User can opt-out (set defaultHold to null)
|
||||
- ✅ Future: UI for self-service profile management
|
||||
**Implementation** (`pkg/appview/handlers/settings.go`):
|
||||
- Requires OAuth session (user must be logged in)
|
||||
- Fetches existing profile or creates new one
|
||||
- Normalizes URLs to DIDs automatically
|
||||
- Updates `updatedAt` timestamp
|
||||
|
||||
### ATProto Client Management
|
||||
|
||||
Progress Summary
|
||||
Users can also manage their profile using standard ATProto tools:
|
||||
|
||||
✅ Completed:
|
||||
1. Created io.atcr.sailor.profile lexicon
|
||||
2. Updated io.atcr.manifest lexicon with holdEndpoint field
|
||||
3. Updated Go types in pkg/atproto/lexicon.go
|
||||
4. Created profile management in pkg/atproto/profile.go
|
||||
5. Updated /auth/exchange handler to manage profile
|
||||
**Get profile:**
|
||||
```bash
|
||||
atproto get-record \
|
||||
--collection io.atcr.sailor.profile \
|
||||
--rkey self
|
||||
```
|
||||
|
||||
⏳ In Progress:
|
||||
- Need to update /auth/token handler similarly (add defaultHoldDID parameter and profile management)
|
||||
- Fix compilation error in extractDefaultHoldDID() - should use configuration.Middleware type not any
|
||||
**Update profile:**
|
||||
```bash
|
||||
atproto put-record \
|
||||
--collection io.atcr.sailor.profile \
|
||||
--rkey self \
|
||||
--value '{
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "did:web:my-hold.example.com",
|
||||
"updatedAt": "2025-10-20T12:00:00Z"
|
||||
}'
|
||||
```
|
||||
|
||||
🔜 Remaining:
|
||||
- Update findStorageEndpoint() for new priority logic (check profile → own hold → default)
|
||||
- Update manifest_store.go to include holdEndpoint when creating manifests
|
||||
- Update pull flow to use manifest holdEndpoint
|
||||
- Update documentation
|
||||
**Clear default hold** (opt out):
|
||||
```bash
|
||||
atproto put-record \
|
||||
--collection io.atcr.sailor.profile \
|
||||
--rkey self \
|
||||
--value '{
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "",
|
||||
"updatedAt": "2025-10-20T12:00:00Z"
|
||||
}'
|
||||
```
|
||||
|
||||
The architecture is solid - just need to finish the token handler update and fix the type issue in the config extraction. Would you like me to continue?
|
||||
## URL-to-DID Migration
|
||||
|
||||
The system automatically migrates old URL-based `defaultHold` values to DID format for consistency:
|
||||
|
||||
**Old format (deprecated):**
|
||||
```json
|
||||
{
|
||||
"defaultHold": "https://hold.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**New format (preferred):**
|
||||
```json
|
||||
{
|
||||
"defaultHold": "did:web:hold.example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Migration behavior:**
|
||||
- `GetProfile()` detects URL format automatically
|
||||
- Converts URL → DID transparently (strips protocol, converts to `did:web:`)
|
||||
- Persists migration to PDS in background goroutine
|
||||
- Uses locks to prevent duplicate migrations
|
||||
- Completely transparent to user
|
||||
|
||||
**Why DIDs?**
|
||||
- **Portable**: DIDs work offline, URLs require DNS
|
||||
- **Canonical**: One DID per hold, multiple URLs possible
|
||||
- **Standard**: ATProto uses DIDs for identity
|
||||
|
||||
## Hold Discovery Flow
|
||||
|
||||
When a user pushes an image, AppView discovers which hold to use:
|
||||
|
||||
```
|
||||
1. User: docker push atcr.io/alice/myapp:latest
|
||||
|
||||
2. AppView resolves alice → did:plc:alice123
|
||||
|
||||
3. AppView calls findHoldDID(did, pdsEndpoint):
|
||||
a. Query alice's PDS for io.atcr.sailor.profile/self
|
||||
b. If profile.defaultHold is set → use it
|
||||
c. Else check alice's io.atcr.hold records (legacy)
|
||||
d. Else use AppView's default_hold_did
|
||||
|
||||
4. Found: alice.profile.defaultHold = "did:web:team-hold.fly.dev"
|
||||
|
||||
5. AppView uses team-hold.fly.dev for blob storage
|
||||
|
||||
6. Manifest stored in alice's PDS includes:
|
||||
- holdDid: "did:web:team-hold.fly.dev" (for future pulls)
|
||||
- holdEndpoint: "https://team-hold.fly.dev" (backward compat)
|
||||
```
|
||||
|
||||
**Implementation** (`pkg/appview/middleware/registry.go:findHoldDID()`):
|
||||
|
||||
```go
|
||||
func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string {
|
||||
client := atproto.NewClient(pdsEndpoint, did, "")
|
||||
|
||||
// 1. Check sailor profile
|
||||
profile, err := atproto.GetProfile(ctx, client)
|
||||
if profile != nil && profile.DefaultHold != "" {
|
||||
return profile.DefaultHold // DID or URL (auto-normalized)
|
||||
}
|
||||
|
||||
// 2. Check own hold records (legacy)
|
||||
records, _ := client.ListRecords(ctx, "io.atcr.hold", 10)
|
||||
for _, record := range records {
|
||||
// Return first hold's endpoint
|
||||
if holdRecord.Endpoint != "" {
|
||||
return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Use AppView default
|
||||
return nr.defaultHoldDID
|
||||
}
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Default Hold (Free Tier)
|
||||
|
||||
User doesn't need to do anything:
|
||||
|
||||
```
|
||||
1. User authenticates to atcr.io
|
||||
2. Profile created with defaultHold = AppView's default
|
||||
3. User pushes images → blobs go to default hold
|
||||
```
|
||||
|
||||
**Profile:**
|
||||
```json
|
||||
{
|
||||
"defaultHold": "did:web:hold01.atcr.io"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Join Team Hold
|
||||
|
||||
User joins a shared team hold:
|
||||
|
||||
```
|
||||
1. Team admin deploys hold service (did:web:team-hold.fly.dev)
|
||||
2. Team admin adds user to crew (via hold's PDS)
|
||||
3. User updates profile:
|
||||
- Via web UI: /settings → set hold to "did:web:team-hold.fly.dev"
|
||||
- Or via ATProto client: put-record
|
||||
4. User pushes images → blobs go to team hold
|
||||
```
|
||||
|
||||
**Profile:**
|
||||
```json
|
||||
{
|
||||
"defaultHold": "did:web:team-hold.fly.dev"
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Team pays for storage (not individual users)
|
||||
- Centralized access control
|
||||
- Shared bandwidth limits
|
||||
|
||||
### 3. Personal Hold (BYOS)
|
||||
|
||||
User deploys their own hold:
|
||||
|
||||
```
|
||||
1. User deploys hold service to Fly.io (did:web:alice-hold.fly.dev)
|
||||
2. Hold auto-creates captain + crew records on first run
|
||||
3. User updates profile to use their hold
|
||||
4. User pushes images → blobs go to personal hold
|
||||
```
|
||||
|
||||
**Profile:**
|
||||
```json
|
||||
{
|
||||
"defaultHold": "did:web:alice-hold.fly.dev"
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Full control over storage
|
||||
- Choose storage provider (S3, Storj, Minio, etc.)
|
||||
- No quotas/limits (except what you pay for)
|
||||
|
||||
### 4. Opt Out of Defaults
|
||||
|
||||
User wants to use only their own hold records (legacy model):
|
||||
|
||||
```json
|
||||
{
|
||||
"defaultHold": ""
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Skips profile's defaultHold (set to empty/null)
|
||||
- Falls back to `io.atcr.hold` records in user's PDS
|
||||
- If no hold records found → uses AppView default
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Why Sailor Profile?
|
||||
|
||||
**Problem solved:**
|
||||
- Users can be crew members of multiple holds
|
||||
- Need explicit way to choose which hold to use
|
||||
- Want to support both personal and shared holds
|
||||
|
||||
**Without sailor profile:**
|
||||
```
|
||||
Alice is crew of:
|
||||
- team-hold.fly.dev (team storage)
|
||||
- community-hold.fly.dev (community storage)
|
||||
|
||||
Which one should AppView use? 🤔
|
||||
```
|
||||
|
||||
**With sailor profile:**
|
||||
```
|
||||
Alice sets profile.defaultHold = "did:web:team-hold.fly.dev"
|
||||
→ AppView knows to use team hold
|
||||
→ Alice can change anytime via settings
|
||||
```
|
||||
|
||||
### Image Ownership vs Hold Choice
|
||||
|
||||
**Key insight:** Image ownership stays with the user, hold is just infrastructure.
|
||||
|
||||
**URL structure:** `atcr.io/<owner>/<image>:<tag>`
|
||||
- Owner = Alice (clear ownership)
|
||||
- Hold = Team storage (infrastructure detail)
|
||||
|
||||
**Analogy:** Like choosing an S3 region
|
||||
- Your files, your ownership
|
||||
- Region is just where bits live
|
||||
- Can move regions without changing ownership
|
||||
|
||||
### Historical Hold References
|
||||
|
||||
Manifests store `holdDid` for immutable blob location tracking:
|
||||
|
||||
```json
|
||||
{
|
||||
"digest": "sha256:abc123",
|
||||
"holdDid": "did:web:team-hold.fly.dev",
|
||||
"holdEndpoint": "https://team-hold.fly.dev",
|
||||
"layers": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**Why store hold in manifest?**
|
||||
- Pull uses historical reference (not re-discovered)
|
||||
- Image stays pullable even if user changes defaultHold
|
||||
- Blobs fetched from where they were originally pushed
|
||||
- Immutable references (manifests don't change)
|
||||
|
||||
**Hold cache:**
|
||||
- In-memory cache: `(userDID, repository) → holdDid`
|
||||
- TTL: 10 minutes (covers typical pull operation)
|
||||
- Avoids re-querying PDS for every blob
|
||||
|
||||
## Configuration
|
||||
|
||||
### AppView Configuration
|
||||
|
||||
```bash
|
||||
# Default hold for new users
|
||||
ATCR_DEFAULT_HOLD_DID=did:web:hold01.atcr.io
|
||||
|
||||
# Test mode: fallback to default if user's hold unreachable
|
||||
ATCR_TEST_MODE=false
|
||||
```
|
||||
|
||||
**Test mode behavior:**
|
||||
- Checks if user's defaultHold is reachable (HTTP/HTTPS)
|
||||
- Falls back to AppView default if unreachable
|
||||
- Useful for local development (prevents errors from unreachable holds)
|
||||
|
||||
### Legacy Support
|
||||
|
||||
**Old hold registration model** (`io.atcr.hold` records in user's PDS):
|
||||
- Still supported for backward compatibility
|
||||
- Checked if profile.defaultHold is empty
|
||||
- New deployments should use sailor profiles instead
|
||||
|
||||
**Migration path:**
|
||||
- Existing holds continue to work
|
||||
- Users with `io.atcr.hold` records can set profile.defaultHold
|
||||
- Profile takes priority over hold records
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Multi-hold support** - Set different holds for different repositories
|
||||
2. **Hold suggestions** - Recommend holds based on geography/cost
|
||||
3. **Hold migration tools** - Move blobs between holds
|
||||
4. **Profile templates** - Pre-configured profiles for teams
|
||||
5. **Hold analytics** - Show storage usage per hold in UI
|
||||
|
||||
## References
|
||||
|
||||
- [BYOS.md](./BYOS.md) - BYOS deployment and hold management
|
||||
- [EMBEDDED_PDS.md](./EMBEDDED_PDS.md) - Hold's embedded PDS architecture
|
||||
- [CREW_ACCESS_CONTROL.md](./CREW_ACCESS_CONTROL.md) - Crew membership and permissions
|
||||
- [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
# SBOM Scanning
|
||||
|
||||
ATCR supports optional Software Bill of Materials (SBOM) generation for container images stored in holds. This feature enables automated security scanning and vulnerability analysis while maintaining the decentralized architecture.
|
||||
|
||||
## Overview
|
||||
|
||||
When enabled, holds automatically generate SBOMs for uploaded container images in the background. The scanning process:
|
||||
|
||||
- **Async execution**: Scanning happens after upload completes (non-blocking)
|
||||
- **ORAS artifacts**: SBOMs stored as OCI Registry as Storage (ORAS) artifacts
|
||||
- **ATProto integration**: Scan results stored as `io.atcr.manifest` records in hold's embedded PDS
|
||||
- **Tool agnostic**: Results accessible via XRPC, ATProto queries, and direct blob URLs
|
||||
- **Opt-in**: Disabled by default, enabled per-hold via configuration
|
||||
|
||||
### Default Scanner: Syft
|
||||
|
||||
ATCR uses [Anchore Syft](https://github.com/anchore/syft) for SBOM generation:
|
||||
- Industry-standard SBOM generator
|
||||
- Supports SPDX and CycloneDX formats
|
||||
- Comprehensive package detection (OS packages, language libraries, etc.)
|
||||
- Active maintenance and CVE database updates
|
||||
|
||||
Future enhancements may include [Grype](https://github.com/anchore/grype) for vulnerability scanning and [Trivy](https://github.com/aquasecurity/trivy) for comprehensive security analysis.
|
||||
|
||||
## Trust Model
|
||||
|
||||
### Same Trust as Docker Hub
|
||||
|
||||
SBOM scanning follows the same trust model as Docker Hub or other centralized registries:
|
||||
|
||||
**Docker Hub model:**
|
||||
- Docker Hub scans your image on their infrastructure
|
||||
- Results stored in their database
|
||||
- You trust Docker Hub's scanner version and scan integrity
|
||||
|
||||
**ATCR hold model:**
|
||||
- Hold scans image on their infrastructure
|
||||
- Results stored in hold's embedded PDS
|
||||
- You trust hold operator's scanner version and scan integrity
|
||||
|
||||
The security comes from **reproducibility** and **transparency**, not storage location:
|
||||
- Anyone can re-scan the same digest and verify results
|
||||
- Multiple holds scanning the same image provide independent verification
|
||||
- Scanner version and scan timestamp are recorded in ATProto records
|
||||
|
||||
### Why Hold's PDS?
|
||||
|
||||
Scan results are stored in the **hold's embedded PDS** rather than the user's PDS:
|
||||
|
||||
**Advantages:**
|
||||
1. **No OAuth expiry issues**: Hold owns its PDS, no service tokens needed
|
||||
2. **Hold-scoped metadata**: Scanner version, scan time, hold configuration
|
||||
3. **Multiple perspectives**: Different holds can scan the same image independently
|
||||
4. **Simpler auth**: Hold writes directly to its own PDS
|
||||
5. **Keeps user PDS lean**: Potentially large SBOM data doesn't bloat user's repo
|
||||
|
||||
**Security properties:**
|
||||
- Same trust level as trusting hold to serve correct blobs
|
||||
- DID signatures prove which hold generated the SBOM
|
||||
- Reproducible scans enable independent verification
|
||||
- Multiple holds scanning same digest → compare results for tampering detection
|
||||
|
||||
## ORAS Manifest Format
|
||||
|
||||
SBOMs are stored as ORAS artifacts that reference their subject image using the OCI referrers specification.
|
||||
|
||||
### Example Manifest Record
|
||||
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.manifest",
|
||||
"repository": "alice/myapp",
|
||||
"digest": "sha256:4a5e...",
|
||||
"holdDid": "did:web:hold01.atcr.io",
|
||||
"holdEndpoint": "https://hold01.atcr.io",
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"artifactType": "application/spdx+json",
|
||||
"subject": {
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"digest": "sha256:abc123...",
|
||||
"size": 1234
|
||||
},
|
||||
"config": {
|
||||
"mediaType": "application/vnd.oci.empty.v1+json",
|
||||
"digest": "sha256:44136f...",
|
||||
"size": 2
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"mediaType": "application/spdx+json",
|
||||
"digest": "sha256:def456...",
|
||||
"size": 5678,
|
||||
"annotations": {
|
||||
"org.opencontainers.image.title": "sbom.spdx.json"
|
||||
}
|
||||
}
|
||||
],
|
||||
"manifestBlob": {
|
||||
"$type": "blob",
|
||||
"ref": { "$link": "bafyrei..." },
|
||||
"mimeType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"size": 789
|
||||
},
|
||||
"ownerDid": "did:plc:alice123",
|
||||
"scannedAt": "2025-10-20T12:34:56.789Z",
|
||||
"scannerVersion": "syft-v1.0.0",
|
||||
"createdAt": "2025-10-20T12:34:56.789Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Key Fields
|
||||
|
||||
- `artifactType`: Distinguishes SBOM artifact from regular image manifest
|
||||
- `application/spdx+json` for SPDX format
|
||||
- `application/vnd.cyclonedx+json` for CycloneDX format
|
||||
- `subject`: Reference to the original image manifest
|
||||
- `ownerDid`: DID of the image owner (for multi-tenant holds)
|
||||
- `scannedAt`: ISO 8601 timestamp of when scan completed
|
||||
- `scannerVersion`: Tool version for reproducibility tracking
|
||||
|
||||
### SBOM Blob
|
||||
|
||||
The actual SBOM document is stored as a blob in the hold's storage backend and referenced in the manifest's `layers` array. The blob contains the full SPDX or CycloneDX JSON document.
|
||||
|
||||
## Configuration
|
||||
|
||||
SBOM scanning is configured via environment variables on the hold service.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Enable SBOM scanning (opt-in)
|
||||
HOLD_SBOM_ENABLED=true
|
||||
|
||||
# Number of concurrent scan workers (default: 2)
|
||||
# Higher values = faster scanning, more CPU/memory usage
|
||||
HOLD_SBOM_WORKERS=4
|
||||
|
||||
# SBOM output format (default: spdx-json)
|
||||
# Options: spdx-json, cyclonedx-json
|
||||
HOLD_SBOM_FORMAT=spdx-json
|
||||
|
||||
# Future: Enable vulnerability scanning with Grype
|
||||
# HOLD_VULN_ENABLED=true
|
||||
```
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```bash
|
||||
# .env.hold
|
||||
HOLD_PUBLIC_URL=https://hold01.atcr.io
|
||||
STORAGE_DRIVER=s3
|
||||
S3_BUCKET=my-hold-blobs
|
||||
HOLD_OWNER=did:plc:xyz123
|
||||
HOLD_DATABASE_PATH=/var/lib/atcr/hold.db
|
||||
|
||||
# Enable SBOM scanning
|
||||
HOLD_SBOM_ENABLED=true
|
||||
HOLD_SBOM_WORKERS=2
|
||||
HOLD_SBOM_FORMAT=spdx-json
|
||||
```
|
||||
|
||||
## Scanning Workflow
|
||||
|
||||
### 1. Upload Completes
|
||||
|
||||
When a container image is successfully pushed to a hold:
|
||||
|
||||
```
|
||||
1. Client: docker push atcr.io/alice/myapp:latest
|
||||
2. AppView routes blobs to hold service
|
||||
3. Hold receives multipart upload via XRPC
|
||||
4. Hold completes upload and stores blobs
|
||||
5. Hold checks: HOLD_SBOM_ENABLED=true?
|
||||
6. If yes: enqueue scan job (non-blocking)
|
||||
7. Upload completes immediately
|
||||
```
|
||||
|
||||
### 2. Background Scanning
|
||||
|
||||
Scan workers process jobs from the queue:
|
||||
|
||||
```
|
||||
1. Worker pulls job from queue
|
||||
2. Extracts image layers from storage
|
||||
3. Runs Syft on extracted filesystem
|
||||
4. Generates SBOM in configured format
|
||||
5. Uploads SBOM blob to storage
|
||||
6. Creates ORAS manifest record in hold's PDS
|
||||
7. Job complete
|
||||
```
|
||||
|
||||
### 3. Result Storage
|
||||
|
||||
SBOM results are stored in two places:
|
||||
|
||||
1. **SBOM blob**: Full JSON document in hold's blob storage
|
||||
2. **ORAS manifest**: Metadata record in hold's embedded PDS
|
||||
- Collection: `io.atcr.manifest`
|
||||
- Record key: SBOM manifest digest
|
||||
- Contains reference to subject image
|
||||
|
||||
## Accessing SBOMs
|
||||
|
||||
Multiple methods for discovering and retrieving SBOM data.
|
||||
|
||||
### 1. XRPC Query Endpoint
|
||||
|
||||
Query for SBOMs by image digest:
|
||||
|
||||
```bash
|
||||
# Get SBOM for a specific image
|
||||
curl "https://hold01.atcr.io/xrpc/io.atcr.hold.getSBOM?\
|
||||
digest=sha256:abc123&\
|
||||
ownerDid=did:plc:alice123&\
|
||||
repository=alice/myapp"
|
||||
|
||||
# Response: ORAS manifest JSON
|
||||
{
|
||||
"manifest": {
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"artifactType": "application/spdx+json",
|
||||
"subject": { "digest": "sha256:abc123...", ... },
|
||||
"layers": [ { "digest": "sha256:def456...", ... } ]
|
||||
},
|
||||
"scannedAt": "2025-10-20T12:34:56.789Z",
|
||||
"scannerVersion": "syft-v1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. ATProto Repository Queries
|
||||
|
||||
Use standard ATProto XRPC to list all SBOMs:
|
||||
|
||||
```bash
|
||||
# List all SBOM manifests in hold's PDS
|
||||
curl "https://hold01.atcr.io/xrpc/com.atproto.repo.listRecords?\
|
||||
repo=did:web:hold01.atcr.io&\
|
||||
collection=io.atcr.manifest"
|
||||
|
||||
# Filter by artifactType (requires AppView indexing)
|
||||
# Returns all SBOM artifacts
|
||||
```
|
||||
|
||||
### 3. Direct SBOM Blob Download
|
||||
|
||||
Download the full SBOM JSON file:
|
||||
|
||||
```bash
|
||||
# Get SBOM blob CID from manifest layers[0].digest
|
||||
SBOM_DIGEST="sha256:def456..."
|
||||
|
||||
# Request presigned download URL
|
||||
curl "https://hold01.atcr.io/xrpc/com.atproto.sync.getBlob?\
|
||||
did=did:web:hold01.atcr.io&\
|
||||
cid=$SBOM_DIGEST"
|
||||
|
||||
# Response: presigned S3 URL or direct blob
|
||||
{
|
||||
"url": "https://s3.amazonaws.com/bucket/blob?signature=...",
|
||||
"expiresAt": "2025-10-20T12:49:56Z"
|
||||
}
|
||||
|
||||
# Download SBOM JSON
|
||||
curl "$URL" > sbom.spdx.json
|
||||
```
|
||||
|
||||
### 4. ORAS CLI Integration
|
||||
|
||||
Use the ORAS CLI to discover and pull SBOMs:
|
||||
|
||||
```bash
|
||||
# Discover referrers (SBOMs) for an image
|
||||
oras discover atcr.io/alice/myapp:latest
|
||||
|
||||
# Output shows SBOM artifacts:
|
||||
# digest: sha256:abc123...
|
||||
# referrers:
|
||||
# - artifactType: application/spdx+json
|
||||
# digest: sha256:4a5e...
|
||||
|
||||
# Pull SBOM artifact
|
||||
oras pull atcr.io/alice/myapp@sha256:4a5e...
|
||||
|
||||
# Downloads sbom.spdx.json to current directory
|
||||
```
|
||||
|
||||
### 5. AppView Web UI (Future)
|
||||
|
||||
Future enhancement: AppView web interface will display SBOM information on repository pages:
|
||||
|
||||
- Link to SBOM JSON download
|
||||
- Vulnerability count (if Grype enabled)
|
||||
- Scanner version and scan timestamp
|
||||
- Comparison across multiple holds
|
||||
|
||||
## Tool Integration
|
||||
|
||||
### SPDX/CycloneDX Tools
|
||||
|
||||
Any tool that understands SPDX or CycloneDX formats can consume the SBOMs:
|
||||
|
||||
**Example tools:**
|
||||
- [OSV Scanner](https://github.com/google/osv-scanner) - Vulnerability scanning
|
||||
- [Grype](https://github.com/anchore/grype) - Vulnerability scanning
|
||||
- [Dependency-Track](https://dependencytrack.org/) - Software composition analysis
|
||||
- [SBOM Quality Score](https://github.com/eBay/sbom-scorecard) - SBOM completeness
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Download SBOM
|
||||
curl "https://hold01.atcr.io/xrpc/io.atcr.hold.getSBOM?..." | \
|
||||
jq -r '.manifest.layers[0].digest' | \
|
||||
# ... fetch blob ... > sbom.spdx.json
|
||||
|
||||
# Scan with OSV
|
||||
osv-scanner --sbom sbom.spdx.json
|
||||
|
||||
# Scan with Grype
|
||||
grype sbom:./sbom.spdx.json
|
||||
```
|
||||
|
||||
### OCI Registry API
|
||||
|
||||
ORAS manifests are fully OCI-compliant and discoverable via standard registry APIs:
|
||||
|
||||
```bash
|
||||
# Discover referrers for an image
|
||||
curl -H "Accept: application/vnd.oci.image.index.v1+json" \
|
||||
"https://atcr.io/v2/alice/myapp/referrers/sha256:abc123"
|
||||
|
||||
# Returns referrers index with SBOM manifests
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": [
|
||||
{
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"digest": "sha256:4a5e...",
|
||||
"artifactType": "application/spdx+json"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Programmatic Access
|
||||
|
||||
Use the ATProto SDK to query SBOMs:
|
||||
|
||||
```go
|
||||
import "github.com/bluesky-social/indigo/atproto"
|
||||
|
||||
// List all SBOMs for a hold
|
||||
records, err := client.RepoListRecords(ctx,
|
||||
"did:web:hold01.atcr.io",
|
||||
"io.atcr.manifest",
|
||||
100, // limit
|
||||
"", // cursor
|
||||
)
|
||||
|
||||
// Filter for SBOM artifacts
|
||||
for _, record := range records.Records {
|
||||
manifest := record.Value.(ManifestRecord)
|
||||
if manifest.ArtifactType == "application/spdx+json" {
|
||||
// Process SBOM manifest
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Vulnerability Scanning (Grype)
|
||||
|
||||
Add vulnerability scanning to SBOM generation:
|
||||
|
||||
```bash
|
||||
# Configuration
|
||||
HOLD_VULN_ENABLED=true
|
||||
HOLD_VULN_DB_UPDATE_INTERVAL=24h
|
||||
|
||||
# Extended manifest with vulnerability count
|
||||
{
|
||||
"artifactType": "application/spdx+json",
|
||||
"annotations": {
|
||||
"io.atcr.vuln.critical": "2",
|
||||
"io.atcr.vuln.high": "15",
|
||||
"io.atcr.vuln.medium": "42",
|
||||
"io.atcr.vuln.low": "8",
|
||||
"io.atcr.vuln.scannedWith": "grype-v0.74.0",
|
||||
"io.atcr.vuln.dbVersion": "2025-10-20"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Scanner Support (Trivy)
|
||||
|
||||
Support multiple scanner backends:
|
||||
|
||||
```bash
|
||||
HOLD_SBOM_SCANNER=trivy # syft (default), trivy, grype
|
||||
HOLD_TRIVY_SCAN_TYPE=os,library,config,secret
|
||||
```
|
||||
|
||||
### Multi-Hold Verification
|
||||
|
||||
Compare SBOMs from different holds for the same image:
|
||||
|
||||
```bash
|
||||
# Alice pushes to hold1 and hold2
|
||||
docker push atcr.io/alice/myapp:latest
|
||||
|
||||
# Both holds scan independently
|
||||
# Compare results:
|
||||
atcr-cli compare-sboms \
|
||||
--image atcr.io/alice/myapp:latest \
|
||||
--holds hold1.atcr.io,hold2.atcr.io
|
||||
|
||||
# Output: Package count differences, version mismatches, etc.
|
||||
```
|
||||
|
||||
### Signature Verification (Cosign)
|
||||
|
||||
Sign SBOMs with Sigstore Cosign:
|
||||
|
||||
```bash
|
||||
HOLD_SBOM_SIGN=true
|
||||
HOLD_COSIGN_KEY_PATH=/var/lib/atcr/cosign.key
|
||||
|
||||
# SBOM artifacts get signed
|
||||
# Verification:
|
||||
cosign verify --key cosign.pub atcr.io/alice/myapp@sha256:4a5e...
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Reproducibility
|
||||
|
||||
SBOMs should be reproducible for the same image digest:
|
||||
|
||||
**Best practices:**
|
||||
- Pin scanner versions in production holds
|
||||
- Record scanner version in manifest annotations
|
||||
- Document vulnerability database versions
|
||||
- Re-scan periodically to catch new CVEs
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# Compare SBOMs from different holds
|
||||
diff <(curl hold1/sbom.json | jq -S) \
|
||||
<(curl hold2/sbom.json | jq -S)
|
||||
|
||||
# Differences indicate:
|
||||
# - Different scanner versions
|
||||
# - Different scan times (new CVEs discovered)
|
||||
# - Potential tampering (investigate)
|
||||
```
|
||||
|
||||
### Multiple Hold Verification
|
||||
|
||||
Running multiple holds provides defense in depth:
|
||||
|
||||
1. User pushes to hold1 (uses hold1 by default)
|
||||
2. User also pushes to hold2 (backup/verification)
|
||||
3. Both holds scan independently
|
||||
4. Compare SBOM results:
|
||||
- Similar results = confidence in accuracy
|
||||
- Divergent results = investigate discrepancy
|
||||
|
||||
### Transparency
|
||||
|
||||
Hold operators should publish scanning policies:
|
||||
|
||||
- Scanner version and update schedule
|
||||
- Vulnerability database update frequency
|
||||
- SBOM format and schema version
|
||||
- Data retention policies
|
||||
|
||||
### Trust Anchors
|
||||
|
||||
Users can verify scanner integrity:
|
||||
|
||||
1. **Scanner version**: Check `scannerVersion` field matches expected version
|
||||
2. **DID signature**: ATProto record signed by hold's DID
|
||||
3. **Timestamp**: Check `scannedAt` for stale scans
|
||||
4. **Reproducibility**: Re-scan locally and compare results
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Enable Scanning on Your Hold
|
||||
|
||||
```bash
|
||||
# 1. Configure hold with SBOM enabled
|
||||
cat > .env.hold <<EOF
|
||||
HOLD_PUBLIC_URL=https://myhold.example.com
|
||||
STORAGE_DRIVER=s3
|
||||
S3_BUCKET=my-blobs
|
||||
HOLD_OWNER=did:plc:myid
|
||||
|
||||
# Enable SBOM scanning
|
||||
HOLD_SBOM_ENABLED=true
|
||||
HOLD_SBOM_WORKERS=2
|
||||
HOLD_SBOM_FORMAT=spdx-json
|
||||
EOF
|
||||
|
||||
# 2. Start hold service
|
||||
./bin/atcr-hold
|
||||
|
||||
# 3. Push an image
|
||||
docker push atcr.io/alice/myapp:latest
|
||||
|
||||
# 4. Wait for background scan (check logs)
|
||||
# 2025-10-20T12:34:56Z INFO Scanning image sha256:abc123...
|
||||
# 2025-10-20T12:35:12Z INFO SBOM generated sha256:def456...
|
||||
|
||||
# 5. Query for SBOM
|
||||
curl "https://myhold.example.com/xrpc/io.atcr.hold.getSBOM?..."
|
||||
```
|
||||
|
||||
### Consume SBOMs in CI/CD
|
||||
|
||||
```yaml
|
||||
# .github/workflows/security-scan.yml
|
||||
name: Security Scan
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Pull image
|
||||
run: docker pull atcr.io/alice/myapp:latest
|
||||
|
||||
- name: Get SBOM from hold
|
||||
run: |
|
||||
IMAGE_DIGEST=$(docker inspect atcr.io/alice/myapp:latest \
|
||||
--format='{{.RepoDigests}}')
|
||||
|
||||
curl "https://hold01.atcr.io/xrpc/io.atcr.hold.getSBOM?\
|
||||
digest=$IMAGE_DIGEST&\
|
||||
ownerDid=did:plc:alice123&\
|
||||
repository=alice/myapp" \
|
||||
-o sbom-manifest.json
|
||||
|
||||
SBOM_DIGEST=$(jq -r '.manifest.layers[0].digest' sbom-manifest.json)
|
||||
|
||||
curl "https://hold01.atcr.io/xrpc/com.atproto.sync.getBlob?\
|
||||
did=did:web:hold01.atcr.io&\
|
||||
cid=$SBOM_DIGEST" \
|
||||
| jq -r '.url' | xargs curl -o sbom.spdx.json
|
||||
|
||||
- name: Scan with Grype
|
||||
uses: anchore/scan-action@v3
|
||||
with:
|
||||
sbom: sbom.spdx.json
|
||||
fail-build: true
|
||||
severity-cutoff: high
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [ORAS Specification](https://oras.land/)
|
||||
- [OCI Artifacts](https://github.com/opencontainers/artifacts)
|
||||
- [SPDX Specification](https://spdx.dev/)
|
||||
- [CycloneDX Specification](https://cyclonedx.org/)
|
||||
- [Syft Documentation](https://github.com/anchore/syft)
|
||||
- [ATProto Specification](https://atproto.com/)
|
||||
@@ -1,821 +0,0 @@
|
||||
# XRPC Blob Upload Migration
|
||||
|
||||
This document describes how to migrate from separate legacy multipart upload endpoints to a unified `com.atproto.repo.uploadBlob` endpoint that supports both standard single-blob uploads and OCI container layer multipart uploads.
|
||||
|
||||
## Current State
|
||||
|
||||
### Legacy HTTP Endpoints (cmd/hold/main.go)
|
||||
|
||||
```go
|
||||
// Unified presigned URL endpoint (handles upload AND download)
|
||||
mux.HandleFunc("/presigned-url", service.HandlePresignedURL)
|
||||
|
||||
// Internal move operation (used by multipart complete)
|
||||
mux.HandleFunc("/move", service.HandleMove)
|
||||
|
||||
// Multipart upload endpoints
|
||||
mux.HandleFunc("/start-multipart", service.HandleStartMultipart)
|
||||
mux.HandleFunc("/part-presigned-url", service.HandleGetPartURL)
|
||||
mux.HandleFunc("/complete-multipart", service.HandleCompleteMultipart)
|
||||
mux.HandleFunc("/abort-multipart", service.HandleAbortMultipart)
|
||||
|
||||
// Buffered part upload (when presigned URLs unavailable)
|
||||
mux.HandleFunc("/multipart-parts/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse URL: /multipart-parts/{uploadID}/{partNumber}
|
||||
// ...
|
||||
service.HandleMultipartPartUpload(w, r, uploadID, partNumber, did, service.MultipartMgr)
|
||||
})
|
||||
```
|
||||
|
||||
### Existing XRPC Endpoint (pkg/hold/pds/xrpc.go)
|
||||
|
||||
```go
|
||||
// Current implementation - redirects to presigned URL
|
||||
func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
|
||||
digest := r.URL.Query().Get("digest")
|
||||
uploadURL, err := h.blobStore.GetPresignedUploadURL(digest)
|
||||
http.Redirect(w, r, uploadURL, http.StatusFound)
|
||||
}
|
||||
```
|
||||
|
||||
### Supporting Code
|
||||
|
||||
**pkg/hold/multipart.go:**
|
||||
- `MultipartManager` - Tracks upload sessions
|
||||
- `MultipartSession` - State for each upload (parts, mode, etc.)
|
||||
- Modes: `S3Native` (presigned URLs), `Buffered` (proxy uploads)
|
||||
|
||||
**pkg/hold/blobstore_adapter.go:**
|
||||
- `HoldServiceBlobStore` - Adapter wrapping HoldService for XRPC handlers
|
||||
- Implements presigned URL generation
|
||||
- Currently not used by XRPC handlers
|
||||
|
||||
**pkg/hold/handlers.go:**
|
||||
- `HandlePresignedURL()` - Unified endpoint for GET/HEAD/PUT presigned URLs
|
||||
- `HandleMove()` - Moves blob from temp to final location (internal operation)
|
||||
- `HandleStartMultipart()` - Starts upload, returns uploadID
|
||||
- `HandleGetPartURL()` - Returns presigned URL for part
|
||||
- `HandleCompleteMultipart()` - Finalizes upload, assembles parts (calls Move internally)
|
||||
- `HandleAbortMultipart()` - Cancels upload
|
||||
- `HandleMultipartPartUpload()` - Buffered part upload fallback
|
||||
|
||||
## Legacy Endpoint Mapping
|
||||
|
||||
### `/presigned-url` → Multiple XRPC Operations
|
||||
|
||||
The legacy `/presigned-url` endpoint is a **unified endpoint** that handles both upload and download operations based on the `operation` field in the JSON body:
|
||||
|
||||
**Legacy format:**
|
||||
```
|
||||
POST /presigned-url
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"operation": "GET", // or "HEAD" or "PUT"
|
||||
"did": "did:plc:alice123",
|
||||
"digest": "sha256:abc123...",
|
||||
"size": 1234567890 // Only for PUT operations
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"url": "https://s3.amazonaws.com/...",
|
||||
"expires_at": "2025-10-16T..."
|
||||
}
|
||||
```
|
||||
|
||||
**XRPC mapping:**
|
||||
- `operation: "GET"` → `GET /xrpc/com.atproto.sync.getBlob?did=...&cid=sha256:abc...`
|
||||
- `operation: "HEAD"` → `HEAD /xrpc/com.atproto.sync.getBlob?did=...&cid=sha256:abc...`
|
||||
- `operation: "PUT"` → `com.atproto.repo.uploadBlob` (single upload via presigned URL)
|
||||
|
||||
**Note:** For GET/HEAD operations, AppView passes OCI digest directly as `cid` parameter. Hold detects `sha256:` prefix and uses digest directly (no CID conversion needed).
|
||||
|
||||
### `/move` → Internal to Multipart Complete
|
||||
|
||||
The legacy `/move` endpoint moves a blob from temporary location to final digest-based location:
|
||||
|
||||
**Legacy format:**
|
||||
```
|
||||
POST /move?from=uploads/temp-123&to=sha256:abc123...&did=did:plc:alice123
|
||||
|
||||
Response: 200 OK
|
||||
```
|
||||
|
||||
**Purpose:** Server-side S3 copy after multipart assembly. Used in this flow:
|
||||
|
||||
1. Multipart parts uploaded → `uploads/temp-{uploadID}/part-1`, `part-2`, etc.
|
||||
2. Complete multipart → S3 assembles parts at `uploads/temp-{uploadID}`
|
||||
3. **Move operation** → S3 copy from `uploads/temp-{uploadID}` → `blobs/sha256/ab/abc123...`
|
||||
|
||||
**XRPC mapping:**
|
||||
- **Not a separate endpoint** - becomes internal operation in `uploadBlob?action=complete`
|
||||
- The `complete` action automatically handles the move after multipart assembly
|
||||
- AppView doesn't need to call move explicitly in XRPC flow
|
||||
|
||||
## New Unified Design
|
||||
|
||||
### Single Endpoint: `com.atproto.repo.uploadBlob`
|
||||
|
||||
Content-Type discrimination determines operation:
|
||||
- `application/octet-stream` → Standard blob upload (profile images, small media)
|
||||
- `application/json` → Multipart operations (large OCI layers)
|
||||
|
||||
### Complementary Endpoint: `com.atproto.sync.getBlob`
|
||||
|
||||
For blob downloads (maps from legacy `/presigned-url` with operation=GET/HEAD):
|
||||
|
||||
**Standard ATProto blobs (CID):**
|
||||
```
|
||||
GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid=bafyreib...
|
||||
|
||||
Response: 307 Temporary Redirect
|
||||
Location: https://s3.amazonaws.com/bucket/...?presigned-params
|
||||
```
|
||||
|
||||
**OCI container layers (digest):**
|
||||
```
|
||||
GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid=sha256:abc123...
|
||||
|
||||
Response: 307 Temporary Redirect
|
||||
Location: https://s3.amazonaws.com/bucket/...?presigned-params
|
||||
```
|
||||
|
||||
**Implementation - Flexible CID parameter:**
|
||||
```go
|
||||
func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
|
||||
cidOrDigest := r.URL.Query().Get("cid")
|
||||
|
||||
var digest string
|
||||
if strings.HasPrefix(cidOrDigest, "sha256:") {
|
||||
// OCI digest - use directly (no conversion needed)
|
||||
digest = cidOrDigest
|
||||
} else {
|
||||
// Standard CID - convert to digest
|
||||
c, _ := cid.Decode(cidOrDigest)
|
||||
digest = cidToDigest(c) // bafyreib... → sha256:abc...
|
||||
}
|
||||
|
||||
// Generate presigned URL for S3
|
||||
url := h.blobStore.GetPresignedDownloadURL(digest)
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight:** The `cid` parameter accepts both formats. Hold service checks prefix and handles accordingly. This keeps the endpoint spec-compliant (GET with query params) while supporting OCI digests natively.
|
||||
|
||||
### API Specification
|
||||
|
||||
#### Standard Single Upload (ATProto Spec Compliant)
|
||||
|
||||
```
|
||||
POST /xrpc/com.atproto.repo.uploadBlob
|
||||
Content-Type: application/octet-stream
|
||||
|
||||
[raw blob bytes]
|
||||
|
||||
Response (200 OK):
|
||||
{
|
||||
"blob": {
|
||||
"$type": "blob",
|
||||
"ref": {
|
||||
"$link": "bafyreib..." // CID
|
||||
},
|
||||
"mimeType": "application/octet-stream",
|
||||
"size": 12345
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use case:** Profile images, small media (< 10MB), standard ATProto blobs
|
||||
|
||||
#### Multipart Start (ATCR Extension)
|
||||
|
||||
```
|
||||
POST /xrpc/com.atproto.repo.uploadBlob
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "start",
|
||||
"digest": "sha256:abc123...",
|
||||
"size": 1234567890 // Optional hint for storage allocation
|
||||
}
|
||||
|
||||
Response (200 OK):
|
||||
{
|
||||
"uploadId": "upload-1634567890",
|
||||
"expiresAt": "2025-10-16T12:00:00Z",
|
||||
"mode": "s3-native" // or "buffered"
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Calls `service.StartMultipartUploadWithManager(ctx, digest, multipartMgr)`
|
||||
- Returns uploadID and mode from MultipartSession
|
||||
|
||||
#### Multipart Get Part URL (ATCR Extension)
|
||||
|
||||
```
|
||||
POST /xrpc/com.atproto.repo.uploadBlob
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "part",
|
||||
"uploadId": "upload-1634567890",
|
||||
"partNumber": 1,
|
||||
"digest": "sha256:abc123..."
|
||||
}
|
||||
|
||||
Response (200 OK):
|
||||
{
|
||||
"url": "https://s3.amazonaws.com/bucket/...?X-Amz-...",
|
||||
"expiresAt": "2025-10-16T12:15:00Z",
|
||||
"method": "PUT"
|
||||
}
|
||||
|
||||
// OR for buffered mode:
|
||||
{
|
||||
"url": "https://hold01.atcr.io/xrpc/com.atproto.repo.uploadBlob",
|
||||
"method": "PUT",
|
||||
"headers": {
|
||||
"X-Upload-Id": "upload-1634567890",
|
||||
"X-Part-Number": "1"
|
||||
},
|
||||
"expiresAt": "2025-10-16T12:15:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Retrieve session: `multipartMgr.GetSession(uploadID)`
|
||||
- S3Native mode: Call `service.GetPartUploadURL(ctx, session, partNumber, did)`
|
||||
- Buffered mode: Return self-referential URL with headers
|
||||
|
||||
#### Multipart Upload Part (Buffered Mode)
|
||||
|
||||
```
|
||||
PUT /xrpc/com.atproto.repo.uploadBlob
|
||||
Content-Type: application/octet-stream
|
||||
X-Upload-Id: upload-1634567890
|
||||
X-Part-Number: 1
|
||||
|
||||
[part data bytes]
|
||||
|
||||
Response (200 OK):
|
||||
{
|
||||
"etag": "abc123def456",
|
||||
"partNumber": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Extract headers: `X-Upload-Id`, `X-Part-Number`
|
||||
- Call `service.HandleMultipartPartUpload(w, r, uploadID, partNumber, did, multipartMgr)`
|
||||
- Return ETag for completion
|
||||
|
||||
#### Multipart Complete (ATCR Extension)
|
||||
|
||||
```
|
||||
POST /xrpc/com.atproto.repo.uploadBlob
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "complete",
|
||||
"uploadId": "upload-1634567890",
|
||||
"digest": "sha256:abc123...",
|
||||
"parts": [
|
||||
{ "partNumber": 1, "etag": "abc123" },
|
||||
{ "partNumber": 2, "etag": "def456" }
|
||||
]
|
||||
}
|
||||
|
||||
Response (200 OK):
|
||||
{
|
||||
"status": "completed",
|
||||
"blob": {
|
||||
"$type": "blob",
|
||||
"ref": {
|
||||
"$link": "bafyreib..." // CID computed from digest
|
||||
},
|
||||
"mimeType": "application/octet-stream",
|
||||
"size": 1234567890
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Retrieve session: `multipartMgr.GetSession(uploadID)`
|
||||
- For S3Native: Record parts via `session.RecordS3Part()`
|
||||
- Call `service.CompleteMultipartUploadWithManager(ctx, session, multipartMgr)`
|
||||
- This internally calls S3 CompleteMultipartUpload to assemble parts
|
||||
- Then performs server-side S3 copy from temp location to final digest location
|
||||
- Equivalent to legacy `/move` endpoint operation
|
||||
- Convert digest to CID for response
|
||||
|
||||
#### Multipart Abort (ATCR Extension)
|
||||
|
||||
```
|
||||
POST /xrpc/com.atproto.repo.uploadBlob
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "abort",
|
||||
"uploadId": "upload-1634567890",
|
||||
"digest": "sha256:abc123..."
|
||||
}
|
||||
|
||||
Response (200 OK):
|
||||
{
|
||||
"status": "aborted"
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Retrieve session: `multipartMgr.GetSession(uploadID)`
|
||||
- Call `service.AbortMultipartUploadWithManager(ctx, session, multipartMgr)`
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase 1: Add Unified Handler (Keep Legacy Endpoints)
|
||||
|
||||
**File:** `pkg/hold/pds/xrpc.go`
|
||||
|
||||
```go
|
||||
// HandleUploadBlob unified handler supporting both single and multipart uploads
|
||||
func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodPut {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
|
||||
// Buffered multipart part upload (PUT with headers)
|
||||
if r.Method == http.MethodPut && r.Header.Get("X-Upload-Id") != "" {
|
||||
h.handleBufferedPartUpload(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Multipart operations (JSON body)
|
||||
if strings.Contains(contentType, "application/json") {
|
||||
h.handleMultipartOperation(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Standard single blob upload (raw bytes)
|
||||
h.handleSingleBlobUpload(w, r)
|
||||
}
|
||||
|
||||
func (h *XRPCHandler) handleMultipartOperation(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
UploadID string `json:"uploadId,omitempty"`
|
||||
PartNumber int `json:"partNumber,omitempty"`
|
||||
Parts []struct {
|
||||
PartNumber int `json:"partNumber"`
|
||||
ETag string `json:"etag"`
|
||||
} `json:"parts,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Add authentication check
|
||||
// user, err := ValidateDPoPRequest(r)
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
switch req.Action {
|
||||
case "start":
|
||||
h.handleMultipartStart(w, r, req.Digest, req.Size)
|
||||
case "part":
|
||||
h.handleMultipartPart(w, r, req.UploadID, req.PartNumber, req.Digest)
|
||||
case "complete":
|
||||
h.handleMultipartComplete(w, r, req.UploadID, req.Digest, req.Parts)
|
||||
case "abort":
|
||||
h.handleMultipartAbort(w, r, req.UploadID, req.Digest)
|
||||
default:
|
||||
http.Error(w, "invalid action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *XRPCHandler) handleMultipartStart(w http.ResponseWriter, r *http.Request, digest string, size int64) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Use HoldService multipart manager
|
||||
// Note: h.blobStore is HoldServiceBlobStore which wraps the service
|
||||
uploadID, mode, err := h.blobStore.StartMultipart(ctx, digest, size)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to start upload: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"uploadId": uploadID,
|
||||
"expiresAt": time.Now().Add(24 * time.Hour),
|
||||
"mode": mode, // "s3-native" or "buffered"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *XRPCHandler) handleMultipartPart(w http.ResponseWriter, r *http.Request, uploadID string, partNumber int, digest string) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get part upload URL (presigned S3 or buffered endpoint)
|
||||
partURL, err := h.blobStore.GetPartUploadURL(ctx, uploadID, partNumber, digest)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to get part URL: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"url": partURL,
|
||||
"expiresAt": time.Now().Add(15 * time.Minute),
|
||||
"method": "PUT",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *XRPCHandler) handleMultipartComplete(w http.ResponseWriter, r *http.Request, uploadID string, digest string, parts []struct{ PartNumber int; ETag string }) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Convert parts format
|
||||
completedParts := make([]hold.CompletedPart, len(parts))
|
||||
for i, p := range parts {
|
||||
completedParts[i] = hold.CompletedPart{
|
||||
PartNumber: p.PartNumber,
|
||||
ETag: p.ETag,
|
||||
}
|
||||
}
|
||||
|
||||
// Complete upload
|
||||
if err := h.blobStore.CompleteMultipart(ctx, uploadID, digest, completedParts); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to complete upload: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert digest to CID for ATProto response format
|
||||
cid, err := digestToCID(digest)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate CID: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"status": "completed",
|
||||
"blob": map[string]any{
|
||||
"$type": "blob",
|
||||
"ref": map[string]any{
|
||||
"$link": cid.String(),
|
||||
},
|
||||
"mimeType": "application/octet-stream",
|
||||
// Size would need to be tracked in session
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *XRPCHandler) handleMultipartAbort(w http.ResponseWriter, r *http.Request, uploadID string, digest string) {
|
||||
ctx := r.Context()
|
||||
|
||||
if err := h.blobStore.AbortMultipart(ctx, uploadID, digest); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to abort upload: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"status": "aborted",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *XRPCHandler) handleBufferedPartUpload(w http.ResponseWriter, r *http.Request) {
|
||||
uploadID := r.Header.Get("X-Upload-Id")
|
||||
partNumberStr := r.Header.Get("X-Part-Number")
|
||||
|
||||
partNumber, err := strconv.Atoi(partNumberStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid part number", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Stream part data to storage
|
||||
etag, err := h.blobStore.UploadPart(r.Context(), uploadID, partNumber, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to upload part: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"etag": etag,
|
||||
"partNumber": partNumber,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *XRPCHandler) handleSingleBlobUpload(w http.ResponseWriter, r *http.Request) {
|
||||
// Standard ATProto uploadBlob behavior
|
||||
// Read blob data
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read blob", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Upload to storage (single operation)
|
||||
cid, size, err := h.blobStore.UploadBlob(r.Context(), bytes.NewReader(data))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to upload blob: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Standard ATProto blob response format
|
||||
response := map[string]any{
|
||||
"blob": map[string]any{
|
||||
"$type": "blob",
|
||||
"ref": map[string]any{
|
||||
"$link": cid.String(),
|
||||
},
|
||||
"mimeType": "application/octet-stream",
|
||||
"size": size,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// digestToCID converts OCI digest (sha256:abc...) to ATProto CID
|
||||
func digestToCID(digest string) (cid.Cid, error) {
|
||||
// Implementation in pkg/hold/cid.go or similar
|
||||
// Strip "sha256:" prefix, decode hex, construct CIDv1 with sha256 multihash
|
||||
return cid.Undef, fmt.Errorf("not implemented")
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Extend HoldServiceBlobStore (pkg/hold/blobstore_adapter.go)
|
||||
|
||||
The `HoldServiceBlobStore` currently wraps HoldService for presigned URLs. Extend it to support multipart operations:
|
||||
|
||||
```go
|
||||
// Add multipart methods to HoldServiceBlobStore
|
||||
|
||||
func (h *HoldServiceBlobStore) StartMultipart(ctx context.Context, digest string, size int64) (uploadID string, mode string, err error) {
|
||||
uploadID, uploadMode, err := h.service.StartMultipartUploadWithManager(ctx, digest, h.service.MultipartMgr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
modeStr := "s3-native"
|
||||
if uploadMode == hold.Buffered {
|
||||
modeStr = "buffered"
|
||||
}
|
||||
|
||||
return uploadID, modeStr, nil
|
||||
}
|
||||
|
||||
func (h *HoldServiceBlobStore) GetPartUploadURL(ctx context.Context, uploadID string, partNumber int, digest string) (string, error) {
|
||||
session, err := h.service.MultipartMgr.GetSession(uploadID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// For S3Native: return presigned URL
|
||||
// For Buffered: return self-referential URL with upload instructions
|
||||
if session.Mode == hold.S3Native {
|
||||
return h.service.GetPartUploadURL(ctx, session, partNumber, h.holdDID)
|
||||
}
|
||||
|
||||
// Buffered mode: client will PUT to uploadBlob with headers
|
||||
return fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", h.publicURL), nil
|
||||
}
|
||||
|
||||
func (h *HoldServiceBlobStore) UploadPart(ctx context.Context, uploadID string, partNumber int, data io.Reader) (string, error) {
|
||||
// Buffered part upload - streams data to storage
|
||||
// Used when client PUTs to uploadBlob with X-Upload-Id header
|
||||
session, err := h.service.MultipartMgr.GetSession(uploadID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Stream to storage, return ETag
|
||||
// This wraps HandleMultipartPartUpload logic
|
||||
etag, err := h.service.UploadPartBuffered(ctx, session, partNumber, data)
|
||||
return etag, err
|
||||
}
|
||||
|
||||
func (h *HoldServiceBlobStore) CompleteMultipart(ctx context.Context, uploadID string, digest string, parts []hold.CompletedPart) error {
|
||||
session, err := h.service.MultipartMgr.GetSession(uploadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For S3Native: record parts ETags
|
||||
if session.Mode == hold.S3Native {
|
||||
for _, p := range parts {
|
||||
session.RecordS3Part(p.PartNumber, p.ETag, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return h.service.CompleteMultipartUploadWithManager(ctx, session, h.service.MultipartMgr)
|
||||
}
|
||||
|
||||
func (h *HoldServiceBlobStore) AbortMultipart(ctx context.Context, uploadID string, digest string) error {
|
||||
session, err := h.service.MultipartMgr.GetSession(uploadID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.service.AbortMultipartUploadWithManager(ctx, session, h.service.MultipartMgr)
|
||||
}
|
||||
|
||||
func (h *HoldServiceBlobStore) UploadBlob(ctx context.Context, data io.Reader) (cid.Cid, int64, error) {
|
||||
// Single blob upload for standard ATProto use case
|
||||
// Compute digest, store via service driver
|
||||
// Return CID and size
|
||||
// Implementation TBD
|
||||
return cid.Undef, 0, fmt.Errorf("not implemented")
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Update AppView Client (pkg/appview/storage/)
|
||||
|
||||
Create new XRPC client or update ProxyBlobStore to use unified endpoint:
|
||||
|
||||
**Download (GET/HEAD):**
|
||||
```go
|
||||
func (p *ProxyBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
|
||||
// Pass digest directly as cid parameter (no conversion)
|
||||
url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
|
||||
p.storageEndpoint, p.holdDID, dgst.String()) // cid=sha256:abc...
|
||||
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Multipart Upload:**
|
||||
```go
|
||||
func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string) (string, error) {
|
||||
reqBody := map[string]any{
|
||||
"action": "start",
|
||||
"digest": digest,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
// ... parse response, return uploadID
|
||||
}
|
||||
|
||||
func (p *ProxyBlobStore) getPartPresignedURL(ctx context.Context, digest, uploadID string, partNumber int) (string, error) {
|
||||
reqBody := map[string]any{
|
||||
"action": "part",
|
||||
"uploadId": uploadID,
|
||||
"partNumber": partNumber,
|
||||
"digest": digest,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
// ... parse response, return presigned URL
|
||||
}
|
||||
|
||||
// Similar for complete, abort
|
||||
```
|
||||
|
||||
### Phase 4: Testing Period
|
||||
|
||||
**During transition:**
|
||||
- Both legacy HTTP endpoints AND new XRPC endpoint active
|
||||
- AppView can use either based on configuration/feature flag
|
||||
- New deployments use XRPC
|
||||
- Old deployments continue with legacy
|
||||
|
||||
**Detection logic:**
|
||||
```go
|
||||
func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
|
||||
// Try XRPC first (check for /.well-known/did.json)
|
||||
if supportsXRPC(storageEndpoint) {
|
||||
return NewXRPCBlobStore(storageEndpoint, ...)
|
||||
}
|
||||
// Fallback to legacy
|
||||
return NewProxyBlobStore(storageEndpoint, ...)
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Remove Legacy Endpoints
|
||||
|
||||
Once all holds migrated and tested:
|
||||
|
||||
**cmd/hold/main.go - Remove:**
|
||||
```go
|
||||
// DELETE these lines
|
||||
mux.HandleFunc("/presigned-url", service.HandlePresignedURL)
|
||||
mux.HandleFunc("/move", service.HandleMove)
|
||||
mux.HandleFunc("/start-multipart", service.HandleStartMultipart)
|
||||
mux.HandleFunc("/part-presigned-url", service.HandleGetPartURL)
|
||||
mux.HandleFunc("/complete-multipart", service.HandleCompleteMultipart)
|
||||
mux.HandleFunc("/abort-multipart", service.HandleAbortMultipart)
|
||||
mux.HandleFunc("/multipart-parts/", ...)
|
||||
```
|
||||
|
||||
**pkg/hold/handlers.go - Remove HTTP handler wrappers:**
|
||||
```go
|
||||
// DELETE these functions:
|
||||
// - HandlePresignedURL() - replaced by uploadBlob + getBlob XRPC endpoints
|
||||
// - HandleMove() - now internal operation in CompleteMultipartUploadWithManager()
|
||||
// - HandleStartMultipart() - replaced by uploadBlob?action=start
|
||||
// - HandleGetPartURL() - replaced by uploadBlob?action=part
|
||||
// - HandleCompleteMultipart() - replaced by uploadBlob?action=complete
|
||||
// - HandleAbortMultipart() - replaced by uploadBlob?action=abort
|
||||
// - HandleMultipartPartUpload() - replaced by uploadBlob PUT with headers
|
||||
|
||||
// KEEP internal service methods:
|
||||
// - s.getPresignedURL() - still used by blobstore_adapter
|
||||
// - s.driver.Move() - still used for temp→final move
|
||||
// - s.StartMultipartUploadWithManager() - core multipart logic
|
||||
// - s.GetPartUploadURL() - presigned URL generation
|
||||
// - s.CompleteMultipartUploadWithManager() - includes move operation
|
||||
// - s.AbortMultipartUploadWithManager() - cleanup logic
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Content-Type discrimination**: Natural way to distinguish single vs multipart uploads
|
||||
2. **JSON bodies for all parameters**: Follows XRPC conventions (like putRecord, deleteRecord)
|
||||
- **No query parameters** - all operation details in request body
|
||||
- Makes requests more inspectable and debuggable
|
||||
- Easier to extend with new fields
|
||||
3. **Preserve standard uploadBlob**: Raw bytes still work for profile images, small media
|
||||
4. **Reuse existing code**: HoldService multipart logic unchanged, just new HTTP layer
|
||||
5. **Backward compatibility**: Both endpoints active during transition
|
||||
6. **Action-based routing**: Clear, extensible JSON structure
|
||||
7. **Move is internal**: `/move` endpoint logic absorbed into multipart complete operation
|
||||
- No separate XRPC endpoint needed
|
||||
- Simplifies AppView client code
|
||||
8. **Unified presigned URL handling**: Single `uploadBlob`/`getBlob` pair replaces operation-based routing
|
||||
9. **Flexible CID parameter**: `getBlob` accepts both standard CIDs and OCI digests via prefix detection
|
||||
- Keeps endpoint spec-compliant (GET with query params)
|
||||
- No conversion overhead on AppView side
|
||||
- Hold does simple prefix check: `sha256:` → use directly, else → convert CID
|
||||
|
||||
## Benefits
|
||||
|
||||
- ✅ Single endpoint for all blob operations
|
||||
- ✅ Standard ATProto uploadBlob preserved
|
||||
- ✅ XRPC-like JSON request/response
|
||||
- ✅ Reuses existing multipart.go logic
|
||||
- ✅ Gradual migration path
|
||||
- ✅ Less endpoints to maintain
|
||||
- ✅ Cleaner AppView client code
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Single blob upload (< 10MB, raw bytes)
|
||||
- [ ] Multipart start → part → complete flow
|
||||
- [ ] S3Native mode (presigned URLs)
|
||||
- [ ] Buffered mode (proxy uploads)
|
||||
- [ ] Multipart abort
|
||||
- [ ] Large blob upload (> 5GB, many parts)
|
||||
- [ ] Concurrent uploads
|
||||
- [ ] Upload resume after network failure
|
||||
- [ ] Legacy endpoint backward compatibility
|
||||
- [ ] AppView XRPC client integration
|
||||
- [ ] Performance comparison (XRPC vs legacy)
|
||||
|
||||
## Migration Timeline
|
||||
|
||||
1. **Week 1**: Implement unified uploadBlob handler (Phase 1-2)
|
||||
2. **Week 2**: Update AppView client, feature flag (Phase 3)
|
||||
3. **Week 3**: Deploy to dev/staging, test both paths (Phase 4)
|
||||
4. **Week 4**: Roll out to production (gradual)
|
||||
5. **Week 5-6**: Monitor, verify all holds migrated
|
||||
6. **Week 7**: Remove legacy endpoints (Phase 5)
|
||||
|
||||
## References
|
||||
|
||||
- ATProto uploadBlob spec: https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
|
||||
- XRPC conventions: https://atproto.com/specs/xrpc
|
||||
- Existing multipart implementation: pkg/hold/multipart.go
|
||||
- Blob store adapter: pkg/hold/blobstore_adapter.go
|
||||
@@ -0,0 +1,19 @@
|
||||
description: Normalize hold_endpoint column to store DIDs instead of URLs
|
||||
query: |
|
||||
-- Convert any URL-formatted hold_endpoint values to DID format
|
||||
-- This ensures all hold identifiers are stored consistently as did:web:hostname
|
||||
|
||||
-- Convert HTTPS URLs to did:web: format
|
||||
-- https://hold.example.com → did:web:hold.example.com
|
||||
UPDATE manifests
|
||||
SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 9)
|
||||
WHERE hold_endpoint LIKE 'https://%';
|
||||
|
||||
-- Convert HTTP URLs to did:web: format
|
||||
-- http://172.28.0.3:8080 → did:web:172.28.0.3:8080
|
||||
UPDATE manifests
|
||||
SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 8)
|
||||
WHERE hold_endpoint LIKE 'http://%';
|
||||
|
||||
-- Entries already in did:web: format are left unchanged
|
||||
-- did:web:hold.example.com → did:web:hold.example.com (no change)
|
||||
+15
-11
@@ -65,17 +65,19 @@ type Tag struct {
|
||||
|
||||
// Push represents a combined tag and manifest for the recent pushes view
|
||||
type Push struct {
|
||||
DID string
|
||||
Handle string
|
||||
Repository string
|
||||
Tag string
|
||||
Digest string
|
||||
Title string
|
||||
Description string
|
||||
IconURL string
|
||||
StarCount int
|
||||
PullCount int
|
||||
CreatedAt time.Time
|
||||
DID string
|
||||
Handle string
|
||||
Repository string
|
||||
Tag string
|
||||
Digest string
|
||||
Title string
|
||||
Description string
|
||||
IconURL string
|
||||
StarCount int
|
||||
PullCount int
|
||||
CreatedAt time.Time
|
||||
HoldEndpoint string // Hold endpoint for health checking
|
||||
Reachable bool // Whether the hold endpoint is reachable
|
||||
}
|
||||
|
||||
// Repository represents an aggregated view of a user's repository
|
||||
@@ -156,4 +158,6 @@ type ManifestWithMetadata struct {
|
||||
Platforms []PlatformInfo
|
||||
PlatformCount int
|
||||
IsManifestList bool
|
||||
Reachable bool // Whether the hold endpoint is reachable
|
||||
Pending bool // Whether health check is still in progress
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
|
||||
COALESCE(m.icon_url, ''),
|
||||
COALESCE(rs.pull_count, 0),
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
|
||||
t.created_at
|
||||
t.created_at,
|
||||
m.hold_endpoint
|
||||
FROM tags t
|
||||
JOIN users u ON t.did = u.did
|
||||
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
|
||||
@@ -70,7 +71,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
|
||||
var pushes []Push
|
||||
for rows.Next() {
|
||||
var p Push
|
||||
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt, &p.HoldEndpoint); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
pushes = append(pushes, p)
|
||||
@@ -113,7 +114,8 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err
|
||||
COALESCE(m.icon_url, ''),
|
||||
COALESCE(rs.pull_count, 0),
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
|
||||
t.created_at
|
||||
t.created_at,
|
||||
m.hold_endpoint
|
||||
FROM tags t
|
||||
JOIN users u ON t.did = u.did
|
||||
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
|
||||
@@ -136,7 +138,7 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err
|
||||
var pushes []Push
|
||||
for rows.Next() {
|
||||
var p Push
|
||||
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt, &p.HoldEndpoint); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
pushes = append(pushes, p)
|
||||
|
||||
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS manifests (
|
||||
did TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
digest TEXT NOT NULL,
|
||||
hold_endpoint TEXT NOT NULL,
|
||||
hold_endpoint TEXT NOT NULL, -- Stored as DID (e.g., did:web:hold.example.com)
|
||||
schema_version INTEGER NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
config_digest TEXT,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/holdhealth"
|
||||
)
|
||||
|
||||
// HomeHandler handles the home page
|
||||
@@ -54,9 +55,10 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// RecentPushesHandler handles the HTMX request for recent pushes
|
||||
type RecentPushesHandler struct {
|
||||
DB *sql.DB
|
||||
Templates *template.Template
|
||||
RegistryURL string
|
||||
DB *sql.DB
|
||||
Templates *template.Template
|
||||
RegistryURL string
|
||||
HealthChecker *holdhealth.Checker
|
||||
}
|
||||
|
||||
func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -78,6 +80,35 @@ func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Check health status and filter out unreachable manifests for home page
|
||||
// Use GetCachedStatus only (no blocking) - background worker keeps cache fresh
|
||||
if h.HealthChecker != nil {
|
||||
reachablePushes := []db.Push{}
|
||||
for i := range pushes {
|
||||
if pushes[i].HoldEndpoint != "" {
|
||||
// Use cached status only - don't block on health checks
|
||||
cached := h.HealthChecker.GetCachedStatus(pushes[i].HoldEndpoint)
|
||||
if cached != nil {
|
||||
pushes[i].Reachable = cached.Reachable
|
||||
// Only show reachable pushes on home page
|
||||
if cached.Reachable {
|
||||
reachablePushes = append(reachablePushes, pushes[i])
|
||||
}
|
||||
} else {
|
||||
// No cached status - optimistically show it (background worker will check)
|
||||
pushes[i].Reachable = true
|
||||
reachablePushes = append(reachablePushes, pushes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
pushes = reachablePushes
|
||||
} else {
|
||||
// If no health checker, assume all are reachable (backward compatibility)
|
||||
for i := range pushes {
|
||||
pushes[i].Reachable = true
|
||||
}
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Pushes []db.Push
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/holdhealth"
|
||||
)
|
||||
|
||||
// ManifestHealthHandler handles HTMX polling for manifest health status
|
||||
type ManifestHealthHandler struct {
|
||||
HealthChecker *holdhealth.Checker
|
||||
}
|
||||
|
||||
func (h *ManifestHealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Get endpoint from query parameter
|
||||
endpoint := r.URL.Query().Get("endpoint")
|
||||
if endpoint == "" {
|
||||
http.Error(w, "endpoint parameter required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode URL-encoded endpoint
|
||||
endpoint, err := url.QueryUnescape(endpoint)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid endpoint parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to get cached status first (instant if background worker has checked it)
|
||||
cached := h.HealthChecker.GetCachedStatus(endpoint)
|
||||
if cached != nil {
|
||||
// Cache hit - return final status
|
||||
h.renderBadge(w, endpoint, cached.Reachable, false)
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss - perform quick check with 2 second timeout
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint)
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
// Still pending - render "Checking..." badge with HTMX retry
|
||||
h.renderBadge(w, endpoint, false, true)
|
||||
} else if err != nil {
|
||||
// Error - mark as unreachable
|
||||
h.renderBadge(w, endpoint, false, false)
|
||||
} else {
|
||||
// Success
|
||||
h.renderBadge(w, endpoint, reachable, false)
|
||||
}
|
||||
}
|
||||
|
||||
// renderBadge renders the appropriate badge HTML snippet
|
||||
func (h *ManifestHealthHandler) renderBadge(w http.ResponseWriter, endpoint string, reachable, pending bool) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
if pending {
|
||||
// Still checking - render badge with HTMX retry after 3 seconds
|
||||
retryURL := "/api/manifest-health?endpoint=" + url.QueryEscape(endpoint)
|
||||
w.Write([]byte(`<span class="checking-badge"
|
||||
hx-get="` + retryURL + `"
|
||||
hx-trigger="load delay:3s"
|
||||
hx-swap="outerHTML">🔄 Checking...</span>`))
|
||||
} else if !reachable {
|
||||
// Unreachable - render offline badge
|
||||
w.Write([]byte(`<span class="offline-badge">⚠️ Offline</span>`))
|
||||
} else {
|
||||
// Reachable - no badge (empty response)
|
||||
w.Write([]byte(``))
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/holdhealth"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
@@ -16,11 +20,12 @@ import (
|
||||
|
||||
// RepositoryPageHandler handles the public repository page
|
||||
type RepositoryPageHandler struct {
|
||||
DB *sql.DB
|
||||
Templates *template.Template
|
||||
RegistryURL string
|
||||
Directory identity.Directory
|
||||
Refresher *oauth.Refresher
|
||||
DB *sql.DB
|
||||
Templates *template.Template
|
||||
RegistryURL string
|
||||
Directory identity.Directory
|
||||
Refresher *oauth.Refresher
|
||||
HealthChecker *holdhealth.Checker
|
||||
}
|
||||
|
||||
func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -54,6 +59,69 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Check health status for each manifest's hold endpoint (concurrent with 1s timeout)
|
||||
if h.HealthChecker != nil {
|
||||
// Create context with 1 second deadline for fast-fail
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := range manifests {
|
||||
if manifests[i].HoldEndpoint == "" {
|
||||
// No hold endpoint, mark as unreachable
|
||||
manifests[i].Reachable = false
|
||||
manifests[i].Pending = false
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
|
||||
endpoint := manifests[idx].HoldEndpoint
|
||||
|
||||
// Try to get cached status first (instant)
|
||||
if cached := h.HealthChecker.GetCachedStatus(endpoint); cached != nil {
|
||||
mu.Lock()
|
||||
manifests[idx].Reachable = cached.Reachable
|
||||
manifests[idx].Pending = false
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Perform health check with timeout context
|
||||
reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint)
|
||||
|
||||
mu.Lock()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
// Timeout - mark as pending for HTMX polling
|
||||
manifests[idx].Reachable = false
|
||||
manifests[idx].Pending = true
|
||||
} else if err != nil {
|
||||
// Error - mark as unreachable
|
||||
manifests[idx].Reachable = false
|
||||
manifests[idx].Pending = false
|
||||
} else {
|
||||
// Success
|
||||
manifests[idx].Reachable = reachable
|
||||
manifests[idx].Pending = false
|
||||
}
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all checks to complete or timeout
|
||||
wg.Wait()
|
||||
} else {
|
||||
// If no health checker, assume all are reachable (backward compatibility)
|
||||
for i := range manifests {
|
||||
manifests[i].Reachable = true
|
||||
manifests[i].Pending = false
|
||||
}
|
||||
}
|
||||
|
||||
if len(tagsWithPlatforms) == 0 && len(manifests) == 0 {
|
||||
http.Error(w, "Repository not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -100,13 +168,13 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Owner *db.User // Repository owner
|
||||
Repository *db.Repository // Repository summary
|
||||
Tags []db.TagWithPlatforms // Tags with platform info
|
||||
Manifests []db.ManifestWithMetadata // Top-level manifests only
|
||||
StarCount int
|
||||
IsStarred bool
|
||||
IsOwner bool // Whether current user owns this repository
|
||||
Owner *db.User // Repository owner
|
||||
Repository *db.Repository // Repository summary
|
||||
Tags []db.TagWithPlatforms // Tags with platform info
|
||||
Manifests []db.ManifestWithMetadata // Top-level manifests only
|
||||
StarCount int
|
||||
IsStarred bool
|
||||
IsOwner bool // Whether current user owns this repository
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
Owner: owner,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package holdhealth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview"
|
||||
)
|
||||
|
||||
// HealthStatus represents the health status of a hold endpoint
|
||||
type HealthStatus struct {
|
||||
Reachable bool
|
||||
LastChecked time.Time
|
||||
LastError error
|
||||
}
|
||||
|
||||
// Checker manages health checking for hold endpoints
|
||||
type Checker struct {
|
||||
client *http.Client
|
||||
cache map[string]*HealthStatus
|
||||
cacheMu sync.RWMutex
|
||||
cacheTTL time.Duration
|
||||
cleanupMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewChecker creates a new health checker with the specified cache TTL
|
||||
func NewChecker(cacheTTL time.Duration) *Checker {
|
||||
return NewCheckerWithTimeout(cacheTTL, 2*time.Second)
|
||||
}
|
||||
|
||||
// NewCheckerWithTimeout creates a new health checker with custom timeout
|
||||
// Useful for testing with shorter timeouts
|
||||
func NewCheckerWithTimeout(cacheTTL, httpTimeout time.Duration) *Checker {
|
||||
return &Checker{
|
||||
client: &http.Client{
|
||||
Timeout: httpTimeout,
|
||||
},
|
||||
cache: make(map[string]*HealthStatus),
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckHealth performs an HTTP health check on the hold endpoint
|
||||
// Accepts either DID (did:web:host) or URL (https://host) format
|
||||
// Checks {endpoint}/xrpc/_health and returns true if reachable
|
||||
func (c *Checker) CheckHealth(ctx context.Context, endpoint string) (bool, error) {
|
||||
// Convert DID to HTTP URL if needed
|
||||
// did:web:hold.example.com → https://hold.example.com
|
||||
// https://hold.example.com → https://hold.example.com (passthrough)
|
||||
httpURL := appview.ResolveHoldURL(endpoint)
|
||||
|
||||
// Build health check URL
|
||||
healthURL := httpURL + "/xrpc/_health"
|
||||
|
||||
// Create request with context
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", healthURL, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Perform request
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// GetStatus returns the cached health status for an endpoint
|
||||
// If the cache is expired or missing, it performs an on-demand check
|
||||
func (c *Checker) GetStatus(ctx context.Context, endpoint string) *HealthStatus {
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
status, exists := c.cache[endpoint]
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// If cached and not expired, return it
|
||||
if exists && time.Since(status.LastChecked) < c.cacheTTL {
|
||||
return status
|
||||
}
|
||||
|
||||
// On-demand check
|
||||
reachable, err := c.CheckHealth(ctx, endpoint)
|
||||
|
||||
// Update cache
|
||||
newStatus := &HealthStatus{
|
||||
Reachable: reachable,
|
||||
LastChecked: time.Now(),
|
||||
LastError: err,
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.cache[endpoint] = newStatus
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return newStatus
|
||||
}
|
||||
|
||||
// GetCachedStatus returns the cached status without performing a check
|
||||
// Returns nil if no cached status exists
|
||||
func (c *Checker) GetCachedStatus(endpoint string) *HealthStatus {
|
||||
c.cacheMu.RLock()
|
||||
defer c.cacheMu.RUnlock()
|
||||
|
||||
status, exists := c.cache[endpoint]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return nil if expired
|
||||
if time.Since(status.LastChecked) > c.cacheTTL {
|
||||
return nil
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// SetStatus manually sets the health status for an endpoint
|
||||
// Used by the background worker to update cache
|
||||
func (c *Checker) SetStatus(endpoint string, reachable bool, err error) {
|
||||
status := &HealthStatus{
|
||||
Reachable: reachable,
|
||||
LastChecked: time.Now(),
|
||||
LastError: err,
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.cache[endpoint] = status
|
||||
c.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
// Cleanup removes stale cache entries (older than 30 minutes)
|
||||
func (c *Checker) Cleanup() {
|
||||
c.cleanupMu.Lock()
|
||||
defer c.cleanupMu.Unlock()
|
||||
|
||||
c.cacheMu.Lock()
|
||||
defer c.cacheMu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-30 * time.Minute)
|
||||
for endpoint, status := range c.cache {
|
||||
if status.LastChecked.Before(cutoff) {
|
||||
delete(c.cache, endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetCacheStats returns cache statistics for debugging
|
||||
func (c *Checker) GetCacheStats() map[string]any {
|
||||
c.cacheMu.RLock()
|
||||
defer c.cacheMu.RUnlock()
|
||||
|
||||
reachable := 0
|
||||
unreachable := 0
|
||||
|
||||
for _, status := range c.cache {
|
||||
if status.Reachable {
|
||||
reachable++
|
||||
} else {
|
||||
unreachable++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"total": len(c.cache),
|
||||
"reachable": reachable,
|
||||
"unreachable": unreachable,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package holdhealth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewChecker(t *testing.T) {
|
||||
cacheTTL := 15 * time.Minute
|
||||
checker := NewChecker(cacheTTL)
|
||||
|
||||
if checker == nil {
|
||||
t.Fatal("NewChecker returned nil")
|
||||
}
|
||||
|
||||
if checker.cacheTTL != cacheTTL {
|
||||
t.Errorf("Expected cache TTL %v, got %v", cacheTTL, checker.cacheTTL)
|
||||
}
|
||||
|
||||
if checker.cache == nil {
|
||||
t.Error("Cache map not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHealth_Success(t *testing.T) {
|
||||
// Create test server that returns 200
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/xrpc/_health" {
|
||||
t.Errorf("Expected path /xrpc/_health, got %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"version": "1.0.0"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
checker := NewChecker(15 * time.Minute)
|
||||
ctx := context.Background()
|
||||
|
||||
reachable, err := checker.CheckHealth(ctx, server.URL)
|
||||
if err != nil {
|
||||
t.Errorf("CheckHealth returned error: %v", err)
|
||||
}
|
||||
|
||||
if !reachable {
|
||||
t.Error("Expected hold to be reachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHealth_WithDID(t *testing.T) {
|
||||
// Create test server that returns 200
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/xrpc/_health" {
|
||||
t.Errorf("Expected path /xrpc/_health, got %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"version": "1.0.0"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
checker := NewChecker(15 * time.Minute)
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with DID format (did:web:host)
|
||||
// Extract host:port from test server URL
|
||||
// http://127.0.0.1:12345 → did:web:127.0.0.1:12345
|
||||
serverURL := server.URL
|
||||
didFormat := "did:web:" + serverURL[7:] // Remove "http://"
|
||||
|
||||
reachable, err := checker.CheckHealth(ctx, didFormat)
|
||||
if err != nil {
|
||||
t.Errorf("CheckHealth with DID returned error: %v", err)
|
||||
}
|
||||
|
||||
if !reachable {
|
||||
t.Error("Expected hold to be reachable with DID format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHealth_Failure(t *testing.T) {
|
||||
// Create test server that returns 500
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
checker := NewChecker(15 * time.Minute)
|
||||
ctx := context.Background()
|
||||
|
||||
reachable, err := checker.CheckHealth(ctx, server.URL)
|
||||
if err == nil {
|
||||
t.Error("Expected error for 500 status code")
|
||||
}
|
||||
|
||||
if reachable {
|
||||
t.Error("Expected hold to be unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHealth_Timeout(t *testing.T) {
|
||||
// Create test server that delays longer than client timeout
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond) // Longer than 100ms test timeout
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Use custom timeout of 100ms for faster test
|
||||
checker := NewCheckerWithTimeout(15*time.Minute, 100*time.Millisecond)
|
||||
ctx := context.Background()
|
||||
|
||||
reachable, err := checker.CheckHealth(ctx, server.URL)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error")
|
||||
}
|
||||
|
||||
if reachable {
|
||||
t.Error("Expected hold to be unreachable due to timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatus_CacheHit(t *testing.T) {
|
||||
checker := NewChecker(15 * time.Minute)
|
||||
endpoint := "https://example.com"
|
||||
|
||||
// Manually set cached status
|
||||
checker.SetStatus(endpoint, true, nil)
|
||||
|
||||
// Get status should return cached value
|
||||
status := checker.GetStatus(context.Background(), endpoint)
|
||||
if status == nil {
|
||||
t.Fatal("GetStatus returned nil")
|
||||
}
|
||||
|
||||
if !status.Reachable {
|
||||
t.Error("Expected cached status to be reachable")
|
||||
}
|
||||
|
||||
if status.LastError != nil {
|
||||
t.Errorf("Expected no error, got %v", status.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatus_CacheMiss(t *testing.T) {
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
checker := NewChecker(15 * time.Minute)
|
||||
|
||||
// Get status should perform check on cache miss
|
||||
status := checker.GetStatus(context.Background(), server.URL)
|
||||
if status == nil {
|
||||
t.Fatal("GetStatus returned nil")
|
||||
}
|
||||
|
||||
if !status.Reachable {
|
||||
t.Error("Expected status to be reachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatus_CacheExpiry(t *testing.T) {
|
||||
// Create checker with very short TTL
|
||||
checker := NewChecker(100 * time.Millisecond)
|
||||
endpoint := "https://example.com"
|
||||
|
||||
// Set cached status
|
||||
checker.SetStatus(endpoint, true, nil)
|
||||
|
||||
// Wait for cache to expire
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// GetCachedStatus should return nil for expired entry
|
||||
status := checker.GetCachedStatus(endpoint)
|
||||
if status != nil {
|
||||
t.Error("Expected nil for expired cache entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStatus(t *testing.T) {
|
||||
checker := NewChecker(15 * time.Minute)
|
||||
endpoint := "https://example.com"
|
||||
|
||||
// Set status
|
||||
checker.SetStatus(endpoint, true, nil)
|
||||
|
||||
// Verify it was set
|
||||
status := checker.GetCachedStatus(endpoint)
|
||||
if status == nil {
|
||||
t.Fatal("Status not found in cache")
|
||||
}
|
||||
|
||||
if !status.Reachable {
|
||||
t.Error("Expected status to be reachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanup(t *testing.T) {
|
||||
checker := NewChecker(1 * time.Minute)
|
||||
|
||||
// Add old entry (simulate old timestamp by manually setting it)
|
||||
endpoint := "https://example.com"
|
||||
checker.cache[endpoint] = &HealthStatus{
|
||||
Reachable: true,
|
||||
LastChecked: time.Now().Add(-31 * time.Minute), // 31 minutes ago
|
||||
}
|
||||
|
||||
// Add recent entry
|
||||
recentEndpoint := "https://recent.com"
|
||||
checker.SetStatus(recentEndpoint, true, nil)
|
||||
|
||||
// Run cleanup
|
||||
checker.Cleanup()
|
||||
|
||||
// Old entry should be removed
|
||||
if checker.GetCachedStatus(endpoint) != nil {
|
||||
t.Error("Expected old entry to be cleaned up")
|
||||
}
|
||||
|
||||
// Recent entry should remain
|
||||
if checker.GetCachedStatus(recentEndpoint) == nil {
|
||||
t.Error("Expected recent entry to remain after cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCacheStats(t *testing.T) {
|
||||
checker := NewChecker(15 * time.Minute)
|
||||
|
||||
// Add some entries
|
||||
checker.SetStatus("https://reachable1.com", true, nil)
|
||||
checker.SetStatus("https://reachable2.com", true, nil)
|
||||
checker.SetStatus("https://unreachable1.com", false, nil)
|
||||
|
||||
stats := checker.GetCacheStats()
|
||||
|
||||
total, ok := stats["total"].(int)
|
||||
if !ok || total != 3 {
|
||||
t.Errorf("Expected total=3, got %v", stats["total"])
|
||||
}
|
||||
|
||||
reachable, ok := stats["reachable"].(int)
|
||||
if !ok || reachable != 2 {
|
||||
t.Errorf("Expected reachable=2, got %v", stats["reachable"])
|
||||
}
|
||||
|
||||
unreachable, ok := stats["unreachable"].(int)
|
||||
if !ok || unreachable != 1 {
|
||||
t.Errorf("Expected unreachable=1, got %v", stats["unreachable"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package holdhealth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DBQuerier interface for database queries (allows mocking in tests)
|
||||
type DBQuerier interface {
|
||||
GetUniqueHoldEndpoints() ([]string, error)
|
||||
}
|
||||
|
||||
// Worker runs background health checks for hold endpoints
|
||||
type Worker struct {
|
||||
checker *Checker
|
||||
db DBQuerier
|
||||
refreshTicker *time.Ticker
|
||||
cleanupTicker *time.Ticker
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewWorker creates a new background worker
|
||||
func NewWorker(checker *Checker, db DBQuerier, refreshInterval time.Duration) *Worker {
|
||||
return &Worker{
|
||||
checker: checker,
|
||||
db: db,
|
||||
refreshTicker: time.NewTicker(refreshInterval),
|
||||
cleanupTicker: time.NewTicker(30 * time.Minute), // Cleanup every 30 minutes
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the background worker
|
||||
func (w *Worker) Start(ctx context.Context) {
|
||||
w.wg.Add(1)
|
||||
go func() {
|
||||
defer w.wg.Done()
|
||||
|
||||
log.Println("Hold health worker: Starting background health checks")
|
||||
|
||||
// Perform initial check immediately
|
||||
w.refreshAllHolds(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("Hold health worker: Context cancelled, stopping")
|
||||
return
|
||||
case <-w.stopChan:
|
||||
log.Println("Hold health worker: Stop signal received")
|
||||
return
|
||||
case <-w.refreshTicker.C:
|
||||
w.refreshAllHolds(ctx)
|
||||
case <-w.cleanupTicker.C:
|
||||
log.Println("Hold health worker: Running cache cleanup")
|
||||
w.checker.Cleanup()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop gracefully stops the worker
|
||||
func (w *Worker) Stop() {
|
||||
close(w.stopChan)
|
||||
w.refreshTicker.Stop()
|
||||
w.cleanupTicker.Stop()
|
||||
w.wg.Wait()
|
||||
log.Println("Hold health worker: Stopped")
|
||||
}
|
||||
|
||||
// refreshAllHolds queries the database for unique hold endpoints and refreshes their health status
|
||||
func (w *Worker) refreshAllHolds(ctx context.Context) {
|
||||
log.Println("Hold health worker: Starting refresh cycle")
|
||||
|
||||
// Get unique hold endpoints from database
|
||||
endpoints, err := w.db.GetUniqueHoldEndpoints()
|
||||
if err != nil {
|
||||
log.Printf("Hold health worker: Failed to fetch hold endpoints: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(endpoints) == 0 {
|
||||
log.Println("Hold health worker: No hold endpoints to check")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Hold health worker: Checking %d unique hold endpoints", len(endpoints))
|
||||
|
||||
// Check health concurrently with rate limiting
|
||||
// Use a semaphore to limit concurrent requests (max 10 at a time)
|
||||
sem := make(chan struct{}, 10)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
reachable := 0
|
||||
unreachable := 0
|
||||
var statsMu sync.Mutex
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
wg.Add(1)
|
||||
|
||||
go func(ep string) {
|
||||
defer wg.Done()
|
||||
|
||||
// Acquire semaphore
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
// Check health
|
||||
isReachable, err := w.checker.CheckHealth(ctx, ep)
|
||||
|
||||
// Update cache
|
||||
w.checker.SetStatus(ep, isReachable, err)
|
||||
|
||||
// Update stats
|
||||
statsMu.Lock()
|
||||
if isReachable {
|
||||
reachable++
|
||||
} else {
|
||||
unreachable++
|
||||
log.Printf("Hold health worker: Hold unreachable: %s (error: %v)", ep, err)
|
||||
}
|
||||
statsMu.Unlock()
|
||||
}(endpoint)
|
||||
}
|
||||
|
||||
// Wait for all checks to complete
|
||||
wg.Wait()
|
||||
|
||||
log.Printf("Hold health worker: Refresh complete - %d reachable, %d unreachable", reachable, unreachable)
|
||||
}
|
||||
|
||||
// DBAdapter wraps sql.DB to implement DBQuerier interface
|
||||
type DBAdapter struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewDBAdapter creates a new database adapter
|
||||
func NewDBAdapter(db *sql.DB) *DBAdapter {
|
||||
return &DBAdapter{db: db}
|
||||
}
|
||||
|
||||
// GetUniqueHoldEndpoints queries the database for unique hold endpoints
|
||||
func (a *DBAdapter) GetUniqueHoldEndpoints() ([]string, error) {
|
||||
rows, err := a.db.Query(`SELECT DISTINCT hold_endpoint FROM manifests WHERE hold_endpoint != ''`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query hold endpoints: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var endpoints []string
|
||||
for rows.Next() {
|
||||
var endpoint string
|
||||
if err := rows.Scan(&endpoint); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan endpoint: %w", err)
|
||||
}
|
||||
endpoints = append(endpoints, endpoint)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating rows: %w", err)
|
||||
}
|
||||
|
||||
return endpoints, nil
|
||||
}
|
||||
@@ -1044,6 +1044,61 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Offline manifest badge */
|
||||
.offline-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Checking manifest badge (health check in progress) */
|
||||
.checking-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border: 1px solid #1976d2;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Hide offline manifests by default */
|
||||
.manifest-item[data-reachable="false"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show offline manifests when toggle is checked */
|
||||
.manifests-list.show-offline .manifest-item[data-reachable="false"] {
|
||||
display: block;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Show offline images toggle styling */
|
||||
.show-offline-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.show-offline-toggle input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.show-offline-toggle span {
|
||||
font-size: 0.9rem;
|
||||
color: var(--border-dark);
|
||||
}
|
||||
|
||||
.manifest-detail-label {
|
||||
font-weight: 500;
|
||||
color: var(--secondary);
|
||||
|
||||
@@ -243,3 +243,41 @@ async function loadStarCount(handle, repository) {
|
||||
console.error('Error loading star count:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle offline manifests visibility
|
||||
function toggleOfflineManifests() {
|
||||
const checkbox = document.getElementById('show-offline-toggle');
|
||||
const manifestsList = document.querySelector('.manifests-list');
|
||||
|
||||
if (!checkbox || !manifestsList) return;
|
||||
|
||||
// Store preference in localStorage
|
||||
localStorage.setItem('showOfflineManifests', checkbox.checked);
|
||||
|
||||
// Toggle visibility of offline manifests
|
||||
if (checkbox.checked) {
|
||||
manifestsList.classList.add('show-offline');
|
||||
} else {
|
||||
manifestsList.classList.remove('show-offline');
|
||||
}
|
||||
}
|
||||
|
||||
// Restore offline manifests toggle state on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const checkbox = document.getElementById('show-offline-toggle');
|
||||
if (!checkbox) return;
|
||||
|
||||
// Restore state from localStorage
|
||||
const showOffline = localStorage.getItem('showOfflineManifests') === 'true';
|
||||
checkbox.checked = showOffline;
|
||||
|
||||
// Apply initial state
|
||||
const manifestsList = document.querySelector('.manifests-list');
|
||||
if (manifestsList) {
|
||||
if (showOffline) {
|
||||
manifestsList.classList.add('show-offline');
|
||||
} else {
|
||||
manifestsList.classList.remove('show-offline');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview"
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/distribution/distribution/v3"
|
||||
"github.com/opencontainers/go-digest"
|
||||
@@ -199,21 +199,10 @@ func (p *ProxyBlobStore) checkWriteAccess(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests
|
||||
// did:web:hold01.atcr.io → https://hold01.atcr.io
|
||||
// did:web:172.28.0.3:8080 → http://172.28.0.3:8080
|
||||
// resolveHoldURL converts a hold identifier (DID or URL) to an HTTP URL
|
||||
// Deprecated: Use appview.ResolveHoldURL instead
|
||||
func resolveHoldURL(holdDID string) string {
|
||||
hostname := strings.TrimPrefix(holdDID, "did:web:")
|
||||
|
||||
// Use HTTP for localhost/IP addresses with ports, HTTPS for domains
|
||||
if strings.Contains(hostname, ":") ||
|
||||
strings.Contains(hostname, "127.0.0.1") ||
|
||||
strings.Contains(hostname, "localhost") ||
|
||||
// Check if it's an IP address (contains only digits and dots)
|
||||
(len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) {
|
||||
return "http://" + hostname
|
||||
}
|
||||
return "https://" + hostname
|
||||
return appview.ResolveHoldURL(holdDID)
|
||||
}
|
||||
|
||||
// Stat returns the descriptor for a blob
|
||||
|
||||
@@ -140,11 +140,17 @@
|
||||
|
||||
<!-- Manifests Section -->
|
||||
<div class="repo-section">
|
||||
<h2>Manifests</h2>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2>Manifests</h2>
|
||||
<label class="show-offline-toggle">
|
||||
<input type="checkbox" id="show-offline-toggle" onchange="toggleOfflineManifests()">
|
||||
<span>Show offline images</span>
|
||||
</label>
|
||||
</div>
|
||||
{{ if .Manifests }}
|
||||
<div class="manifests-list">
|
||||
{{ range .Manifests }}
|
||||
<div class="manifest-item" id="manifest-{{ sanitizeID .Manifest.Digest }}">
|
||||
<div class="manifest-item" id="manifest-{{ sanitizeID .Manifest.Digest }}" data-reachable="{{ .Reachable }}">
|
||||
<div class="manifest-item-header">
|
||||
<div>
|
||||
{{ if .IsManifestList }}
|
||||
@@ -152,6 +158,16 @@
|
||||
{{ else }}
|
||||
<span class="manifest-type">📄 Image</span>
|
||||
{{ end }}
|
||||
{{ if .Pending }}
|
||||
<span class="checking-badge"
|
||||
hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}"
|
||||
hx-trigger="load delay:2s"
|
||||
hx-swap="outerHTML">
|
||||
🔄 Checking...
|
||||
</span>
|
||||
{{ else if not .Reachable }}
|
||||
<span class="offline-badge">⚠️ Offline</span>
|
||||
{{ end }}
|
||||
<code class="manifest-digest">{{ .Manifest.Digest }}</code>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; align-items: center;">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package appview
|
||||
|
||||
import "strings"
|
||||
|
||||
// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL
|
||||
// Handles both formats for backward compatibility:
|
||||
// - DID format: did:web:hold01.atcr.io → https://hold01.atcr.io
|
||||
// - DID with port: did:web:172.28.0.3:8080 → http://172.28.0.3:8080
|
||||
// - URL format: https://hold.example.com → https://hold.example.com (passthrough)
|
||||
func ResolveHoldURL(holdIdentifier string) string {
|
||||
// If it's already a URL (has scheme), return as-is
|
||||
if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
|
||||
return holdIdentifier
|
||||
}
|
||||
|
||||
// If it's a DID, convert to URL
|
||||
if strings.HasPrefix(holdIdentifier, "did:web:") {
|
||||
hostname := strings.TrimPrefix(holdIdentifier, "did:web:")
|
||||
|
||||
// Use HTTP for localhost/IP addresses with ports, HTTPS for domains
|
||||
if strings.Contains(hostname, ":") ||
|
||||
strings.Contains(hostname, "127.0.0.1") ||
|
||||
strings.Contains(hostname, "localhost") ||
|
||||
// Check if it's an IP address (contains only digits and dots in first part)
|
||||
(len(hostname) > 0 && hostname[0] >= '0' && hostname[0] <= '9') {
|
||||
return "http://" + hostname
|
||||
}
|
||||
return "https://" + hostname
|
||||
}
|
||||
|
||||
// Fallback: assume it's a hostname and use HTTPS
|
||||
return "https://" + holdIdentifier
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package appview
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveHoldURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "DID with HTTPS domain",
|
||||
input: "did:web:hold.example.com",
|
||||
expected: "https://hold.example.com",
|
||||
},
|
||||
{
|
||||
name: "DID with HTTP and port (IP)",
|
||||
input: "did:web:172.28.0.3:8080",
|
||||
expected: "http://172.28.0.3:8080",
|
||||
},
|
||||
{
|
||||
name: "DID with HTTP and port (localhost)",
|
||||
input: "did:web:127.0.0.1:8080",
|
||||
expected: "http://127.0.0.1:8080",
|
||||
},
|
||||
{
|
||||
name: "DID with localhost",
|
||||
input: "did:web:localhost:8080",
|
||||
expected: "http://localhost:8080",
|
||||
},
|
||||
{
|
||||
name: "Already HTTPS URL (passthrough)",
|
||||
input: "https://hold.example.com",
|
||||
expected: "https://hold.example.com",
|
||||
},
|
||||
{
|
||||
name: "Already HTTP URL (passthrough)",
|
||||
input: "http://172.28.0.3:8080",
|
||||
expected: "http://172.28.0.3:8080",
|
||||
},
|
||||
{
|
||||
name: "Plain hostname (fallback to HTTPS)",
|
||||
input: "hold.example.com",
|
||||
expected: "https://hold.example.com",
|
||||
},
|
||||
{
|
||||
name: "DID with subdomain",
|
||||
input: "did:web:hold01.atcr.io",
|
||||
expected: "https://hold01.atcr.io",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ResolveHoldURL(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -149,15 +149,15 @@ func TestEnsureProfile_Exists(t *testing.T) {
|
||||
// TestGetProfile tests retrieving a user's profile
|
||||
func TestGetProfile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverResponse string
|
||||
serverStatus int
|
||||
wantProfile *SailorProfileRecord
|
||||
wantNil bool
|
||||
wantErr bool
|
||||
expectMigration bool // Whether URL-to-DID migration should happen
|
||||
originalHoldURL string
|
||||
expectedHoldDID string
|
||||
name string
|
||||
serverResponse string
|
||||
serverStatus int
|
||||
wantProfile *SailorProfileRecord
|
||||
wantNil bool
|
||||
wantErr bool
|
||||
expectMigration bool // Whether URL-to-DID migration should happen
|
||||
originalHoldURL string
|
||||
expectedHoldDID string
|
||||
}{
|
||||
{
|
||||
name: "profile with DID (no migration needed)",
|
||||
@@ -359,10 +359,10 @@ func TestGetProfile_MigrationLocking(t *testing.T) {
|
||||
// TestUpdateProfile tests updating a user's profile
|
||||
func TestUpdateProfile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profile *SailorProfileRecord
|
||||
wantNormalized string // Expected defaultHold after normalization
|
||||
wantErr bool
|
||||
name string
|
||||
profile *SailorProfileRecord
|
||||
wantNormalized string // Expected defaultHold after normalization
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "update with DID",
|
||||
|
||||
Reference in New Issue
Block a user