A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

implement stars and pull tracking rename registry -> appview

+751 -221
+4 -4
CLAUDE.md
··· 11 11 ```bash 12 12 # Build all binaries 13 13 # create go builds in the bin/ directory 14 - go build -o bin/atcr-registry ./cmd/registry 14 + go build -o bin/atcr-appview ./cmd/appview 15 15 go build -o bin/atcr-hold ./cmd/hold 16 16 go build -o bin/docker-credential-atcr ./cmd/credential-helper 17 17 ··· 25 25 go mod tidy 26 26 27 27 # Build Docker images 28 - docker build -t atcr.io/registry:latest . 28 + docker build -t atcr.io/appview:latest . 29 29 docker build -f Dockerfile.hold -t atcr.io/hold:latest . 30 30 31 31 # Or use docker-compose ··· 34 34 # Run locally (AppView) 35 35 export ATPROTO_DID=did:plc:your-did 36 36 export ATPROTO_ACCESS_TOKEN=your-token 37 - ./atcr-registry serve config/config.yml 37 + ./atcr-appview serve config/config.yml 38 38 39 39 # Run hold service (configure via env vars - see .env.example) 40 40 export HOLD_PUBLIC_URL=http://127.0.0.1:8080 ··· 57 57 58 58 ### Three-Component Architecture 59 59 60 - 1. **AppView** (`cmd/registry`) - OCI Distribution API server 60 + 1. **AppView** (`cmd/appview`) - OCI Distribution API server 61 61 - Resolves identities (handle/DID → PDS endpoint) 62 62 - Routes manifests to user's PDS 63 63 - Routes blobs to storage endpoint (default or BYOS)
+6 -6
Dockerfile
··· 17 17 COPY . . 18 18 19 19 # Build the binary with CGO enabled for SQLite support 20 - RUN CGO_ENABLED=1 GOOS=linux go build -a -o atcr-registry ./cmd/registry 20 + RUN CGO_ENABLED=1 GOOS=linux go build -a -o atcr-appview ./cmd/appview 21 21 22 22 # Runtime stage 23 23 FROM alpine:latest ··· 29 29 WORKDIR /app 30 30 31 31 # Copy binary from builder 32 - COPY --from=builder /build/atcr-registry . 32 + COPY --from=builder /build/atcr-appview . 33 33 34 34 # Copy default configuration 35 35 COPY config/config.yml /etc/atcr/config.yml ··· 44 44 ENV ATCR_CONFIG=/etc/atcr/config.yml 45 45 46 46 # OCI image annotations 47 - LABEL org.opencontainers.image.title="ATCR Registry" \ 47 + LABEL org.opencontainers.image.title="ATCR AppView" \ 48 48 org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \ 49 49 org.opencontainers.image.authors="ATCR Contributors" \ 50 50 org.opencontainers.image.source="https://github.com/example/atcr" \ 51 51 org.opencontainers.image.documentation="https://atcr.io/docs" \ 52 52 org.opencontainers.image.licenses="MIT" \ 53 53 org.opencontainers.image.version="0.1.0" \ 54 - io.atcr.icon="https://atcr.io/images/registry-icon.png" 54 + io.atcr.icon="https://atcr.io/images/appview-icon.png" 55 55 56 - # Run the registry 57 - ENTRYPOINT ["/app/atcr-registry"] 56 + # Run the AppView 57 + ENTRYPOINT ["/app/atcr-appview"] 58 58 CMD ["serve", "/etc/atcr/config.yml"]
+15 -15
README.md
··· 26 26 27 27 ```bash 28 28 # Build all binaries locally 29 - go build -o atcr-registry ./cmd/registry 29 + go build -o atcr-appview ./cmd/appview 30 30 go build -o atcr-hold ./cmd/hold 31 31 go build -o docker-credential-atcr ./cmd/credential-helper 32 32 33 33 # Build Docker images 34 - docker build -t atcr.io/registry:latest . 34 + docker build -t atcr.io/appview:latest . 35 35 docker build -f Dockerfile.hold -t atcr.io/hold:latest . 36 36 ``` 37 37 ··· 56 56 sudo chown -R $USER:$USER /var/lib/atcr 57 57 58 58 # 2. Build binaries 59 - go build -o atcr-registry ./cmd/registry 59 + go build -o atcr-appview ./cmd/appview 60 60 go build -o atcr-hold ./cmd/hold 61 61 62 62 # 3. Configure environment ··· 66 66 67 67 # 4. Start services 68 68 # Terminal 1: 69 - ./atcr-registry serve config/config.yml 69 + ./atcr-appview serve config/config.yml 70 70 # Terminal 2 (will prompt for OAuth): 71 71 ./atcr-hold config/hold.yml 72 72 # Follow OAuth URL in logs to authorize ··· 94 94 export $(cat .env | xargs) 95 95 ``` 96 96 97 - **AppView (Registry):** 97 + **AppView:** 98 98 ```bash 99 - ./atcr-registry serve config/config.yml 99 + ./atcr-appview serve config/config.yml 100 100 ``` 101 101 102 102 **Hold (Storage Service):** ··· 115 115 116 116 **Or run containers separately:** 117 117 118 - **AppView (Registry):** 118 + **AppView:** 119 119 ```bash 120 120 docker run -d \ 121 - --name atcr-registry \ 121 + --name atcr-appview \ 122 122 -p 5000:5000 \ 123 123 -e ATPROTO_DID=did:plc:your-did \ 124 124 -e ATPROTO_ACCESS_TOKEN=your-access-token \ 125 125 -e AWS_ACCESS_KEY_ID=your-aws-key \ 126 126 -e AWS_SECRET_ACCESS_KEY=your-aws-secret \ 127 127 -v $(pwd)/config/config.yml:/etc/atcr/config.yml \ 128 - atcr.io/registry:latest 128 + atcr.io/appview:latest 129 129 ``` 130 130 131 131 **Hold (Storage Service):** ··· 145 145 apiVersion: apps/v1 146 146 kind: Deployment 147 147 metadata: 148 - name: atcr-registry 148 + name: atcr-appview 149 149 spec: 150 150 replicas: 3 151 151 selector: 152 152 matchLabels: 153 - app: atcr-registry 153 + app: atcr-appview 154 154 template: 155 155 metadata: 156 156 labels: 157 - app: atcr-registry 157 + app: atcr-appview 158 158 spec: 159 159 containers: 160 - - name: registry 161 - image: atcr.io/registry:latest 160 + - name: appview 161 + image: atcr.io/appview:latest 162 162 ports: 163 163 - containerPort: 5000 164 164 env: ··· 215 215 216 216 ``` 217 217 atcr.io/ 218 - ├── cmd/registry/ # Main entrypoint 218 + ├── cmd/appview/ # AppView entrypoint 219 219 ├── pkg/ 220 220 │ ├── atproto/ # ATProto client and manifest store 221 221 │ ├── storage/ # S3 blob store and routing
+3 -3
SPEC.md
··· 35 35 36 36 1. Initialize Go module with github.com/distribution/distribution/v3 and github.com/bluesky-social/indigo 37 37 2. Create basic project structure 38 - 3. Set up cmd/registry/main.go that imports distribution and registers middleware 38 + 3. Set up cmd/appview/main.go that imports distribution and registers middleware 39 39 40 40 Phase 2: Core ATProto Integration 41 41 ··· 78 78 Phase 6: Configuration & Deployment 79 79 80 80 13. Create registry configuration (config/config.yml) 81 - 14. Create Dockerfile for building atcr-registry binary 81 + 14. Create Dockerfile for building atcr-appview binary 82 82 16. Write README.md with usage instructions 83 83 84 84 Phase 7: Documentation ··· 193 193 atproto: 194 194 # Used by auth service to validate credentials 195 195 pds_endpoint: https://bsky.social 196 - client_id: atcr-registry 196 + client_id: atcr-appview 197 197 oauth_redirect: http://localhost:8888/callback 198 198 199 199 ATProto OAuth Implementation Plan
cmd/registry/main.go cmd/appview/main.go
+37
cmd/registry/serve.go cmd/appview/serve.go
··· 107 107 // 6. Set global refresher for middleware 108 108 middleware.SetGlobalRefresher(refresher) 109 109 110 + // 6.5. Set global database for pull/push metrics tracking 111 + metricsDB := db.NewMetricsDB(uiDatabase) 112 + middleware.SetGlobalDatabase(metricsDB) 113 + 110 114 // 7. Initialize UI routes with OAuth app, refresher, and device store 111 115 uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiSessionStore, oauthApp, refresher, baseURL, deviceStore) 112 116 ··· 419 423 DB: database, 420 424 Templates: templates, 421 425 RegistryURL: uihandlers.TrimRegistryURL(baseURL), 426 + }, 427 + )).Methods("GET") 428 + 429 + // API route for repository stats (public) 430 + router.Handle("/api/stats/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( 431 + &uihandlers.GetStatsHandler{ 432 + DB: database, 433 + Directory: oauthApp.Directory(), 434 + }, 435 + )).Methods("GET") 436 + 437 + // API routes for stars (require authentication) 438 + router.Handle("/api/stars/{handle}/{repository}", appmiddleware.RequireAuth(sessionStore, database)( 439 + &uihandlers.StarRepositoryHandler{ 440 + DB: database, 441 + Directory: oauthApp.Directory(), 442 + Refresher: refresher, 443 + }, 444 + )).Methods("POST") 445 + 446 + router.Handle("/api/stars/{handle}/{repository}", appmiddleware.RequireAuth(sessionStore, database)( 447 + &uihandlers.UnstarRepositoryHandler{ 448 + DB: database, 449 + Directory: oauthApp.Directory(), 450 + Refresher: refresher, 451 + }, 452 + )).Methods("DELETE") 453 + 454 + router.Handle("/api/stars/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( 455 + &uihandlers.CheckStarHandler{ 456 + DB: database, 457 + Directory: oauthApp.Directory(), 458 + Refresher: refresher, 422 459 }, 423 460 )).Methods("GET") 424 461
+2 -2
config/config.yml
··· 3 3 level: info 4 4 formatter: text 5 5 fields: 6 - service: atcr-registry 6 + service: atcr-appview 7 7 8 8 # Storage is handled by external services: 9 9 # - Manifests/Tags -> ATProto PDS (user's personal data server) 10 10 # - Blobs/Layers -> Hold service (default or BYOS) 11 - # The AppView (registry) should be stateless with no local storage 11 + # The AppView should be stateless with no local storage 12 12 # 13 13 # NOTE: The storage section below is required for distribution config validation 14 14 # but is NOT actually used - all blob operations are routed through hold service
+4 -4
docker-compose.yml
··· 1 1 services: 2 - atcr-registry: 2 + atcr-appview: 3 3 build: 4 4 context: . 5 5 dockerfile: Dockerfile 6 - image: atcr-registry:latest 7 - container_name: atcr-registry 6 + image: atcr-appview:latest 7 + container_name: atcr-appview 8 8 ports: 9 9 - "5000:5000" 10 10 environment: ··· 22 22 networks: 23 23 atcr-network: 24 24 ipv4_address: 172.28.0.2 25 - # The registry should be stateless - all storage is external: 25 + # The AppView should be stateless - all storage is external: 26 26 # - Manifests/Tags -> ATProto PDS 27 27 # - Blobs/Layers -> Hold service 28 28 # - OAuth tokens -> Persistent volume (atcr-tokens)
+4 -4
docs/API_KEY_MIGRATION.md
··· 326 326 - `pkg/auth/exchange/handler.go` 327 327 328 328 **Files to update:** 329 - - `cmd/registry/serve.go` - Remove exchange handler registration 329 + - `cmd/appview/serve.go` - Remove exchange handler registration 330 330 331 331 ### Phase 3: Update UI 332 332 ··· 489 489 </style> 490 490 ``` 491 491 492 - #### 3.2 Register API Key Routes (`cmd/registry/serve.go`) 492 + #### 3.2 Register API Key Routes (`cmd/appview/serve.go`) 493 493 494 494 ```go 495 495 // In initializeUI() function, add: ··· 678 678 } 679 679 ``` 680 680 681 - #### 5.4 Update Registry Initialization (`cmd/registry/serve.go`) 681 + #### 5.4 Update Registry Initialization (`cmd/appview/serve.go`) 682 682 683 683 ```go 684 684 // REMOVE session manager creation: ··· 800 800 - `pkg/appview/handlers/settings.go` - Add API key management UI 801 801 - `pkg/appview/templates/settings.html` - Add API key section 802 802 - `cmd/credential-helper/main.go` - Simplify to use API keys 803 - - `cmd/registry/serve.go` - Initialize API key store, remove session manager 803 + - `cmd/appview/serve.go` - Initialize API key store, remove session manager 804 804 805 805 ### Deleted Files 806 806 - `pkg/auth/session/handler.go` - Session token system
+9 -9
docs/APPVIEW-UI-IMPLEMENTATION.md
··· 14 14 ## Project Structure 15 15 16 16 ``` 17 - cmd/registry/ 17 + cmd/appview/ 18 18 ├── main.go # Add AppView routes here 19 19 20 20 pkg/appview/ ··· 1249 1249 1250 1250 ## Step 8: Main Integration 1251 1251 1252 - **cmd/registry/main.go (additions):** 1252 + **cmd/appview/main.go (additions):** 1253 1253 1254 1254 ```go 1255 1255 package main ··· 1747 1747 ### Development 1748 1748 ```bash 1749 1749 # Run migrations 1750 - go run cmd/registry/main.go migrate 1750 + go run cmd/appview/main.go migrate 1751 1751 1752 1752 # Start server 1753 - go run cmd/registry/main.go serve 1753 + go run cmd/appview/main.go serve 1754 1754 ``` 1755 1755 1756 1756 ### Production 1757 1757 ```bash 1758 1758 # Build binary 1759 - go build -o bin/atcr-registry ./cmd/registry 1759 + go build -o bin/atcr-appview ./cmd/appview 1760 1760 1761 1761 # Run with config 1762 - ./bin/atcr-registry serve config/production.yml 1762 + ./bin/atcr-appview serve config/production.yml 1763 1763 ``` 1764 1764 1765 1765 ### Environment Variables ··· 1786 1786 ### Single Binary Deployment 1787 1787 - All templates and static files embedded with `//go:embed` 1788 1788 - No need to ship separate `web/` directory 1789 - - Single `atcr-registry` binary contains everything 1789 + - Single `atcr-appview` binary contains everything 1790 1790 - Easy deployment: just copy one file 1791 1791 1792 1792 ### Package Structure ··· 1807 1807 1808 1808 **Build:** 1809 1809 ```bash 1810 - go build -o bin/atcr-registry ./cmd/registry 1810 + go build -o bin/atcr-appview ./cmd/appview 1811 1811 ``` 1812 1812 1813 1813 **Deploy:** 1814 1814 ```bash 1815 - scp bin/atcr-registry server:/usr/local/bin/ 1815 + scp bin/atcr-appview server:/usr/local/bin/ 1816 1816 # Done! No webpack, no node_modules, no separate assets folder 1817 1817 ``` 1818 1818
+6 -6
docs/TESTING.md
··· 24 24 ### 2. Build Binaries 25 25 26 26 ```bash 27 - go build -o atcr-registry ./cmd/registry 27 + go build -o atcr-appview ./cmd/appview 28 28 go build -o atcr-hold ./cmd/hold 29 29 go build -o docker-credential-atcr ./cmd/credential-helper 30 30 ``` ··· 62 62 63 63 ### 4. Start Services 64 64 65 - **Terminal 1 - Registry:** 65 + **Terminal 1 - AppView:** 66 66 ```bash 67 - ./atcr-registry serve config/config.yml 67 + ./atcr-appview serve config/config.yml 68 68 ``` 69 69 70 70 **Terminal 2 - Hold:** ··· 74 74 75 75 ### 5. Start Services and OAuth Registration 76 76 77 - **Terminal 1 - Registry:** 77 + **Terminal 1 - AppView:** 78 78 ```bash 79 - ./atcr-registry serve config/config.yml 79 + ./atcr-appview serve config/config.yml 80 80 ``` 81 81 82 82 **Terminal 2 - Hold (OAuth registration):** ··· 295 295 kill $(cat .atcr-pids) 296 296 297 297 # Or manually 298 - pkill atcr-registry 298 + pkill atcr-appview 299 299 pkill atcr-hold 300 300 ``` 301 301
+4
lexicons/io/atcr/manifest.json
··· 58 58 "ref": "#blobReference", 59 59 "description": "Optional reference to another manifest (for attestations, signatures)" 60 60 }, 61 + "manifestBlob": { 62 + "type": "blob", 63 + "description": "The full OCI manifest stored as a blob in ATProto." 64 + }, 61 65 "createdAt": { 62 66 "type": "string", 63 67 "format": "datetime",
+44
lexicons/io/atcr/sailor/star.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.sailor.star", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A star (like) on a container image repository. Stored in the starrer's PDS, similar to Bluesky likes.", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "ref", 15 + "ref": "#subject", 16 + "description": "The repository being starred" 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "Star creation timestamp" 22 + } 23 + } 24 + } 25 + }, 26 + "subject": { 27 + "type": "object", 28 + "description": "Reference to a repository owned by a user", 29 + "required": ["did", "repository"], 30 + "properties": { 31 + "did": { 32 + "type": "string", 33 + "format": "did", 34 + "description": "DID of the repository owner" 35 + }, 36 + "repository": { 37 + "type": "string", 38 + "description": "Repository name (e.g., 'myapp')", 39 + "maxLength": 255 40 + } 41 + } 42 + } 43 + } 44 + }
+11 -1
pkg/appview/db/models.go
··· 22 22 MediaType string 23 23 ConfigDigest string 24 24 ConfigSize int64 25 - RawManifest string // JSON 26 25 CreatedAt time.Time 27 26 Title string 28 27 Description string ··· 77 76 Licenses string 78 77 IconURL string 79 78 } 79 + 80 + // RepositoryStats represents statistics for a repository 81 + type RepositoryStats struct { 82 + DID string 83 + Repository string 84 + StarCount int 85 + PullCount int 86 + LastPull *time.Time 87 + PushCount int 88 + LastPush *time.Time 89 + }
+148 -9
pkg/appview/db/queries.go
··· 135 135 // Get manifests for this repo 136 136 manifestRows, err := db.Query(` 137 137 SELECT id, digest, hold_endpoint, schema_version, media_type, 138 - config_digest, config_size, raw_manifest, created_at, 138 + config_digest, config_size, created_at, 139 139 title, description, source_url, documentation_url, licenses, icon_url 140 140 FROM manifests 141 141 WHERE did = ? AND repository = ? ··· 155 155 var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString 156 156 157 157 if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 158 - &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt, 158 + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt, 159 159 &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil { 160 160 manifestRows.Close() 161 161 return nil, err ··· 384 384 result, err := db.Exec(` 385 385 INSERT OR IGNORE INTO manifests 386 386 (did, repository, digest, hold_endpoint, schema_version, media_type, 387 - config_digest, config_size, raw_manifest, created_at, 387 + config_digest, config_size, created_at, 388 388 title, description, source_url, documentation_url, licenses, icon_url) 389 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 389 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 390 390 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, 391 391 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, 392 - manifest.ConfigSize, manifest.RawManifest, manifest.CreatedAt, 392 + manifest.ConfigSize, manifest.CreatedAt, 393 393 manifest.Title, manifest.Description, manifest.SourceURL, 394 394 manifest.DocumentationURL, manifest.Licenses, manifest.IconURL) 395 395 ··· 446 446 447 447 err := db.QueryRow(` 448 448 SELECT id, did, repository, digest, hold_endpoint, schema_version, 449 - media_type, config_digest, config_size, raw_manifest, created_at, 449 + media_type, config_digest, config_size, created_at, 450 450 title, description, source_url, documentation_url, licenses, icon_url 451 451 FROM manifests 452 452 WHERE digest = ? 453 453 `, digest).Scan(&m.ID, &m.DID, &m.Repository, &m.Digest, &m.HoldEndpoint, 454 454 &m.SchemaVersion, &m.MediaType, &m.ConfigDigest, &m.ConfigSize, 455 - &m.RawManifest, &m.CreatedAt, 455 + &m.CreatedAt, 456 456 &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL) 457 457 458 458 if err != nil { ··· 698 698 // Get manifests for this repo 699 699 manifestRows, err := db.Query(` 700 700 SELECT id, digest, hold_endpoint, schema_version, media_type, 701 - config_digest, config_size, raw_manifest, created_at, 701 + config_digest, config_size, created_at, 702 702 title, description, source_url, documentation_url, licenses, icon_url 703 703 FROM manifests 704 704 WHERE did = ? AND repository = ? ··· 718 718 var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString 719 719 720 720 if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 721 - &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt, 721 + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.CreatedAt, 722 722 &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil { 723 723 manifestRows.Close() 724 724 return nil, err ··· 761 761 762 762 return &r, nil 763 763 } 764 + 765 + // GetRepositoryStats fetches stats for a repository 766 + func GetRepositoryStats(db *sql.DB, did, repository string) (*RepositoryStats, error) { 767 + var stats RepositoryStats 768 + var lastPullStr, lastPushStr sql.NullString 769 + 770 + err := db.QueryRow(` 771 + SELECT did, repository, star_count, pull_count, last_pull, push_count, last_push 772 + FROM repository_stats 773 + WHERE did = ? AND repository = ? 774 + `, did, repository).Scan(&stats.DID, &stats.Repository, &stats.StarCount, &stats.PullCount, &lastPullStr, &stats.PushCount, &lastPushStr) 775 + 776 + if err == sql.ErrNoRows { 777 + // Return zero stats if no record exists yet 778 + return &RepositoryStats{ 779 + DID: did, 780 + Repository: repository, 781 + StarCount: 0, 782 + PullCount: 0, 783 + PushCount: 0, 784 + }, nil 785 + } 786 + if err != nil { 787 + return nil, err 788 + } 789 + 790 + // Parse timestamps 791 + if lastPullStr.Valid { 792 + t, err := parseTimestamp(lastPullStr.String) 793 + if err == nil { 794 + stats.LastPull = &t 795 + } 796 + } 797 + if lastPushStr.Valid { 798 + t, err := parseTimestamp(lastPushStr.String) 799 + if err == nil { 800 + stats.LastPush = &t 801 + } 802 + } 803 + 804 + return &stats, nil 805 + } 806 + 807 + // UpsertRepositoryStats inserts or updates repository stats 808 + func UpsertRepositoryStats(db *sql.DB, stats *RepositoryStats) error { 809 + _, err := db.Exec(` 810 + INSERT INTO repository_stats (did, repository, star_count, pull_count, last_pull, push_count, last_push) 811 + VALUES (?, ?, ?, ?, ?, ?, ?) 812 + ON CONFLICT(did, repository) DO UPDATE SET 813 + star_count = excluded.star_count, 814 + pull_count = excluded.pull_count, 815 + last_pull = excluded.last_pull, 816 + push_count = excluded.push_count, 817 + last_push = excluded.last_push 818 + `, stats.DID, stats.Repository, stats.StarCount, stats.PullCount, stats.LastPull, stats.PushCount, stats.LastPush) 819 + return err 820 + } 821 + 822 + // IncrementStarCount increments the star count for a repository 823 + func IncrementStarCount(db *sql.DB, did, repository string) error { 824 + _, err := db.Exec(` 825 + INSERT INTO repository_stats (did, repository, star_count) 826 + VALUES (?, ?, 1) 827 + ON CONFLICT(did, repository) DO UPDATE SET 828 + star_count = star_count + 1 829 + `, did, repository) 830 + return err 831 + } 832 + 833 + // DecrementStarCount decrements the star count for a repository 834 + func DecrementStarCount(db *sql.DB, did, repository string) error { 835 + _, err := db.Exec(` 836 + UPDATE repository_stats 837 + SET star_count = MAX(0, star_count - 1) 838 + WHERE did = ? AND repository = ? 839 + `, did, repository) 840 + return err 841 + } 842 + 843 + // IncrementPullCount increments the pull count for a repository 844 + func IncrementPullCount(db *sql.DB, did, repository string) error { 845 + _, err := db.Exec(` 846 + INSERT INTO repository_stats (did, repository, pull_count, last_pull) 847 + VALUES (?, ?, 1, datetime('now')) 848 + ON CONFLICT(did, repository) DO UPDATE SET 849 + pull_count = pull_count + 1, 850 + last_pull = datetime('now') 851 + `, did, repository) 852 + return err 853 + } 854 + 855 + // IncrementPushCount increments the push count for a repository 856 + func IncrementPushCount(db *sql.DB, did, repository string) error { 857 + _, err := db.Exec(` 858 + INSERT INTO repository_stats (did, repository, push_count, last_push) 859 + VALUES (?, ?, 1, datetime('now')) 860 + ON CONFLICT(did, repository) DO UPDATE SET 861 + push_count = push_count + 1, 862 + last_push = datetime('now') 863 + `, did, repository) 864 + return err 865 + } 866 + 867 + // parseTimestamp parses a timestamp string with multiple format attempts 868 + func parseTimestamp(s string) (time.Time, error) { 869 + formats := []string{ 870 + time.RFC3339Nano, 871 + "2006-01-02 15:04:05.999999999-07:00", 872 + "2006-01-02 15:04:05.999999999", 873 + time.RFC3339, 874 + "2006-01-02 15:04:05", 875 + } 876 + for _, format := range formats { 877 + if t, err := time.Parse(format, s); err == nil { 878 + return t, nil 879 + } 880 + } 881 + return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", s) 882 + } 883 + 884 + // MetricsDB wraps a sql.DB and implements the metrics interface for middleware 885 + type MetricsDB struct { 886 + db *sql.DB 887 + } 888 + 889 + // NewMetricsDB creates a new metrics database wrapper 890 + func NewMetricsDB(db *sql.DB) *MetricsDB { 891 + return &MetricsDB{db: db} 892 + } 893 + 894 + // IncrementPullCount increments the pull count for a repository 895 + func (m *MetricsDB) IncrementPullCount(did, repository string) error { 896 + return IncrementPullCount(m.db, did, repository) 897 + } 898 + 899 + // IncrementPushCount increments the push count for a repository 900 + func (m *MetricsDB) IncrementPushCount(did, repository string) error { 901 + return IncrementPushCount(m.db, did, repository) 902 + }
+29 -22
pkg/appview/db/schema.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 - "strings" 6 5 7 6 _ "github.com/mattn/go-sqlite3" 8 7 ) ··· 28 27 media_type TEXT NOT NULL, 29 28 config_digest TEXT, 30 29 config_size INTEGER, 31 - raw_manifest TEXT NOT NULL, 32 30 created_at TIMESTAMP NOT NULL, 33 31 title TEXT, 34 32 description TEXT, ··· 142 140 ); 143 141 CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code); 144 142 CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at); 143 + 144 + CREATE TABLE IF NOT EXISTS repository_stats ( 145 + did TEXT NOT NULL, 146 + repository TEXT NOT NULL, 147 + star_count INTEGER NOT NULL DEFAULT 0, 148 + pull_count INTEGER NOT NULL DEFAULT 0, 149 + last_pull TIMESTAMP, 150 + push_count INTEGER NOT NULL DEFAULT 0, 151 + last_push TIMESTAMP, 152 + PRIMARY KEY(did, repository), 153 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 154 + ); 155 + CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did); 156 + CREATE INDEX IF NOT EXISTS idx_repository_stats_star_count ON repository_stats(star_count DESC); 157 + CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC); 145 158 ` 146 159 147 160 // InitDB initializes the SQLite database with the schema ··· 161 174 return nil, err 162 175 } 163 176 164 - // Migration: Add avatar column if it doesn't exist 165 - _, err = db.Exec(`ALTER TABLE users ADD COLUMN avatar TEXT`) 166 - // Ignore error if column already exists 167 - if err != nil && !strings.Contains(err.Error(), "duplicate column") { 168 - // Log but don't fail - column might already exist 177 + // Migration: Drop raw_manifest column if it exists 178 + // Check if column exists first 179 + var columnExists bool 180 + err = db.QueryRow(` 181 + SELECT COUNT(*) > 0 182 + FROM pragma_table_info('manifests') 183 + WHERE name = 'raw_manifest' 184 + `).Scan(&columnExists) 185 + if err != nil { 186 + return nil, err 169 187 } 170 188 171 - // Migration: Add OCI annotation columns to manifests table 172 - annotationColumns := []string{ 173 - "title TEXT", 174 - "description TEXT", 175 - "source_url TEXT", 176 - "documentation_url TEXT", 177 - "licenses TEXT", 178 - "icon_url TEXT", 179 - } 180 - for _, col := range annotationColumns { 181 - colName := strings.Split(col, " ")[0] 182 - _, err = db.Exec(`ALTER TABLE manifests ADD COLUMN ` + col) 183 - if err != nil && !strings.Contains(err.Error(), "duplicate column") { 184 - // Log but continue - column might already exist 185 - println("Warning: Failed to add column", colName, "to manifests:", err.Error()) 189 + if columnExists { 190 + // Drop the column (requires SQLite 3.35.0+) 191 + if _, err := db.Exec(`ALTER TABLE manifests DROP COLUMN raw_manifest`); err != nil { 192 + return nil, err 186 193 } 187 194 } 188 195
+226
pkg/appview/handlers/api.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "net/http" 8 + 9 + "atcr.io/pkg/appview/db" 10 + "atcr.io/pkg/appview/middleware" 11 + "atcr.io/pkg/atproto" 12 + "atcr.io/pkg/auth/oauth" 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/gorilla/mux" 16 + ) 17 + 18 + // StarRepositoryHandler handles starring a repository 19 + type StarRepositoryHandler struct { 20 + DB *sql.DB 21 + Directory identity.Directory 22 + Refresher *oauth.Refresher 23 + } 24 + 25 + func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 26 + // Get authenticated user from middleware 27 + user := middleware.GetUser(r) 28 + if user == nil { 29 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 30 + return 31 + } 32 + 33 + // Extract parameters 34 + vars := mux.Vars(r) 35 + handle := vars["handle"] 36 + repository := vars["repository"] 37 + 38 + // Resolve owner's handle to DID 39 + ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) 40 + if err != nil { 41 + http.Error(w, "Failed to resolve handle", http.StatusBadRequest) 42 + return 43 + } 44 + 45 + // Get OAuth session for the authenticated user 46 + session, err := h.Refresher.GetSession(r.Context(), user.DID) 47 + if err != nil { 48 + http.Error(w, "Failed to get OAuth session", http.StatusUnauthorized) 49 + return 50 + } 51 + 52 + // Get user's PDS client (use indigo's API client which handles DPoP automatically) 53 + apiClient := session.APIClient() 54 + pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) 55 + 56 + // Create star record 57 + starRecord := atproto.NewStarRecord(ownerDID, repository) 58 + rkey := atproto.StarRecordKey(ownerDID, repository) 59 + 60 + // Write star record to user's PDS 61 + _, err = pdsClient.PutRecord(r.Context(), atproto.StarCollection, rkey, starRecord) 62 + if err != nil { 63 + http.Error(w, "Failed to create star", http.StatusInternalServerError) 64 + return 65 + } 66 + 67 + // Return success 68 + w.Header().Set("Content-Type", "application/json") 69 + w.WriteHeader(http.StatusCreated) 70 + json.NewEncoder(w).Encode(map[string]bool{"starred": true}) 71 + } 72 + 73 + // UnstarRepositoryHandler handles unstarring a repository 74 + type UnstarRepositoryHandler struct { 75 + DB *sql.DB 76 + Directory identity.Directory 77 + Refresher *oauth.Refresher 78 + } 79 + 80 + func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 81 + // Get authenticated user from middleware 82 + user := middleware.GetUser(r) 83 + if user == nil { 84 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 85 + return 86 + } 87 + 88 + // Extract parameters 89 + vars := mux.Vars(r) 90 + handle := vars["handle"] 91 + repository := vars["repository"] 92 + 93 + // Resolve owner's handle to DID 94 + ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) 95 + if err != nil { 96 + http.Error(w, "Failed to resolve handle", http.StatusBadRequest) 97 + return 98 + } 99 + 100 + // Get OAuth session for the authenticated user 101 + session, err := h.Refresher.GetSession(r.Context(), user.DID) 102 + if err != nil { 103 + http.Error(w, "Failed to get OAuth session", http.StatusUnauthorized) 104 + return 105 + } 106 + 107 + // Get user's PDS client (use indigo's API client which handles DPoP automatically) 108 + apiClient := session.APIClient() 109 + pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) 110 + 111 + // Delete star record from user's PDS 112 + rkey := atproto.StarRecordKey(ownerDID, repository) 113 + err = pdsClient.DeleteRecord(r.Context(), atproto.StarCollection, rkey) 114 + if err != nil { 115 + // If record doesn't exist, still return success (idempotent) 116 + if err.Error() != "record not found" { 117 + http.Error(w, "Failed to delete star", http.StatusInternalServerError) 118 + return 119 + } 120 + } 121 + 122 + // Return success 123 + w.Header().Set("Content-Type", "application/json") 124 + json.NewEncoder(w).Encode(map[string]bool{"starred": false}) 125 + } 126 + 127 + // CheckStarHandler checks if current user has starred a repository 128 + type CheckStarHandler struct { 129 + DB *sql.DB 130 + Directory identity.Directory 131 + Refresher *oauth.Refresher 132 + } 133 + 134 + func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 135 + // Get authenticated user from middleware 136 + user := middleware.GetUser(r) 137 + if user == nil { 138 + // Not authenticated - return not starred 139 + w.Header().Set("Content-Type", "application/json") 140 + json.NewEncoder(w).Encode(map[string]bool{"starred": false}) 141 + return 142 + } 143 + 144 + // Extract parameters 145 + vars := mux.Vars(r) 146 + handle := vars["handle"] 147 + repository := vars["repository"] 148 + 149 + // Resolve owner's handle to DID 150 + ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) 151 + if err != nil { 152 + http.Error(w, "Failed to resolve handle", http.StatusBadRequest) 153 + return 154 + } 155 + 156 + // Get OAuth session for the authenticated user 157 + session, err := h.Refresher.GetSession(r.Context(), user.DID) 158 + if err != nil { 159 + // No OAuth session - return not starred 160 + w.Header().Set("Content-Type", "application/json") 161 + json.NewEncoder(w).Encode(map[string]bool{"starred": false}) 162 + return 163 + } 164 + 165 + // Get user's PDS client (use indigo's API client which handles DPoP automatically) 166 + apiClient := session.APIClient() 167 + pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) 168 + 169 + // Check if star record exists 170 + rkey := atproto.StarRecordKey(ownerDID, repository) 171 + _, err = pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey) 172 + 173 + starred := err == nil 174 + 175 + // Return result 176 + w.Header().Set("Content-Type", "application/json") 177 + json.NewEncoder(w).Encode(map[string]bool{"starred": starred}) 178 + } 179 + 180 + // GetStatsHandler returns repository statistics 181 + type GetStatsHandler struct { 182 + DB *sql.DB 183 + Directory identity.Directory 184 + } 185 + 186 + func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 187 + // Extract parameters 188 + vars := mux.Vars(r) 189 + handle := vars["handle"] 190 + repository := vars["repository"] 191 + 192 + // Resolve owner's handle to DID 193 + ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) 194 + if err != nil { 195 + http.Error(w, "Failed to resolve handle", http.StatusBadRequest) 196 + return 197 + } 198 + 199 + // Get repository stats from database 200 + stats, err := db.GetRepositoryStats(h.DB, ownerDID, repository) 201 + if err != nil { 202 + http.Error(w, "Failed to fetch stats", http.StatusInternalServerError) 203 + return 204 + } 205 + 206 + // Return stats as JSON 207 + w.Header().Set("Content-Type", "application/json") 208 + json.NewEncoder(w).Encode(stats) 209 + } 210 + 211 + // resolveIdentityToDID is a helper function that resolves a handle or DID to a DID 212 + func resolveIdentityToDID(ctx context.Context, directory identity.Directory, identityStr string) (string, error) { 213 + // Parse as AT identifier (handle or DID) 214 + atID, err := syntax.ParseAtIdentifier(identityStr) 215 + if err != nil { 216 + return "", err 217 + } 218 + 219 + // Resolve to DID via directory 220 + ident, err := directory.Lookup(ctx, *atID) 221 + if err != nil { 222 + return "", err 223 + } 224 + 225 + return ident.DID.String(), nil 226 + }
-50
pkg/appview/interfaces.go
··· 1 - package appview 2 - 3 - import "time" 4 - 5 - // SessionStore interface for UI session management 6 - // Implemented by both session.Store (file-based) and db.SessionStore (SQLite-based) 7 - type SessionStore interface { 8 - Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) 9 - CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) 10 - Get(id string) (Session, bool) 11 - Delete(id string) 12 - Cleanup() 13 - } 14 - 15 - // Session represents a user session 16 - // Compatible with both file-based and SQLite implementations 17 - type Session interface { 18 - GetID() string 19 - GetDID() string 20 - GetHandle() string 21 - GetPDSEndpoint() string 22 - GetOAuthSessionID() string 23 - } 24 - 25 - // DeviceStore interface for device authorization management 26 - // Implemented by both device.Store (file-based) and db.DeviceStore (SQLite-based) 27 - type DeviceStore interface { 28 - CreatePendingAuth(deviceName, ip, userAgent string) (PendingAuth, error) 29 - GetPendingByUserCode(userCode string) (PendingAuth, bool) 30 - GetPendingByDeviceCode(deviceCode string) (PendingAuth, bool) 31 - ApprovePending(userCode, did, handle string) (deviceSecret string, err error) 32 - ValidateDeviceSecret(secret string) (Device, error) 33 - ListDevices(did string) []Device 34 - RevokeDevice(did, deviceID string) error 35 - CleanupExpired() 36 - } 37 - 38 - // PendingAuth interface for pending device authorizations 39 - type PendingAuth interface { 40 - GetDeviceCode() string 41 - GetUserCode() string 42 - GetDeviceName() string 43 - } 44 - 45 - // Device interface for authorized devices 46 - type Device interface { 47 - GetID() string 48 - GetDID() string 49 - GetHandle() string 50 - }
+40 -14
pkg/appview/jetstream/backfill.go
··· 52 52 collections := []string{ 53 53 atproto.ManifestCollection, // io.atcr.manifest 54 54 atproto.TagCollection, // io.atcr.tag 55 + atproto.StarCollection, // io.atcr.sailor.star 55 56 } 56 57 57 58 for _, collection := range collections { ··· 243 244 return b.processManifestRecord(did, record) 244 245 case atproto.TagCollection: 245 246 return b.processTagRecord(did, record) 247 + case atproto.StarCollection: 248 + return b.processStarRecord(did, record) 246 249 default: 247 250 return fmt.Errorf("unsupported collection: %s", collection) 248 251 } ··· 255 258 return fmt.Errorf("failed to unmarshal manifest: %w", err) 256 259 } 257 260 258 - // Serialize full manifest as JSON for storage 259 - manifestJSON, err := json.Marshal(manifestRecord) 260 - if err != nil { 261 - return fmt.Errorf("failed to marshal manifest: %w", err) 261 + // Extract OCI annotations from manifest 262 + var title, description, sourceURL, documentationURL, licenses, iconURL string 263 + if manifestRecord.Annotations != nil { 264 + title = manifestRecord.Annotations["org.opencontainers.image.title"] 265 + description = manifestRecord.Annotations["org.opencontainers.image.description"] 266 + sourceURL = manifestRecord.Annotations["org.opencontainers.image.source"] 267 + documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"] 268 + licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"] 269 + iconURL = manifestRecord.Annotations["io.atcr.icon"] 262 270 } 263 271 264 272 // Insert manifest 265 273 manifestID, err := db.InsertManifest(b.db, &db.Manifest{ 266 - DID: did, 267 - Repository: manifestRecord.Repository, 268 - Digest: manifestRecord.Digest, 269 - MediaType: manifestRecord.MediaType, 270 - SchemaVersion: manifestRecord.SchemaVersion, 271 - ConfigDigest: manifestRecord.Config.Digest, 272 - ConfigSize: manifestRecord.Config.Size, 273 - RawManifest: string(manifestJSON), 274 - HoldEndpoint: manifestRecord.HoldEndpoint, 275 - CreatedAt: manifestRecord.CreatedAt, 274 + DID: did, 275 + Repository: manifestRecord.Repository, 276 + Digest: manifestRecord.Digest, 277 + MediaType: manifestRecord.MediaType, 278 + SchemaVersion: manifestRecord.SchemaVersion, 279 + ConfigDigest: manifestRecord.Config.Digest, 280 + ConfigSize: manifestRecord.Config.Size, 281 + HoldEndpoint: manifestRecord.HoldEndpoint, 282 + CreatedAt: manifestRecord.CreatedAt, 283 + Title: title, 284 + Description: description, 285 + SourceURL: sourceURL, 286 + DocumentationURL: documentationURL, 287 + Licenses: licenses, 288 + IconURL: iconURL, 276 289 }) 277 290 if err != nil { 278 291 // Skip if already exists ··· 314 327 Digest: tagRecord.ManifestDigest, 315 328 CreatedAt: tagRecord.UpdatedAt, 316 329 }) 330 + } 331 + 332 + // processStarRecord processes a star record 333 + func (b *BackfillWorker) processStarRecord(did string, record *atproto.Record) error { 334 + var starRecord atproto.StarRecord 335 + if err := json.Unmarshal(record.Value, &starRecord); err != nil { 336 + return fmt.Errorf("failed to unmarshal star: %w", err) 337 + } 338 + 339 + // Increment star count for the repository being starred 340 + // The DID here is the starrer (user who starred) 341 + // The subject contains the owner DID and repository 342 + return db.IncrementStarCount(b.db, starRecord.Subject.DID, starRecord.Subject.Repository) 317 343 } 318 344 319 345 // ensureUser resolves and upserts a user by DID
+49 -7
pkg/appview/jetstream/worker.go
··· 53 53 wantedCollections: []string{ 54 54 atproto.ManifestCollection, // io.atcr.manifest 55 55 atproto.TagCollection, // io.atcr.tag 56 + atproto.StarCollection, // io.atcr.sailor.star 56 57 }, 57 58 userCache: &UserCache{ 58 59 cache: make(map[string]*db.User), ··· 199 200 fmt.Printf("Jetstream: Processing tag event: did=%s, operation=%s, rkey=%s\n", 200 201 commit.DID, commit.Operation, commit.RKey) 201 202 return w.processTag(commit) 203 + case atproto.StarCollection: 204 + fmt.Printf("Jetstream: Processing star event: did=%s, operation=%s, rkey=%s\n", 205 + commit.DID, commit.Operation, commit.RKey) 206 + return w.processStar(commit) 202 207 default: 203 208 // Ignore other collections 204 209 return nil ··· 310 315 return nil 311 316 } 312 317 313 - // Serialize full manifest as JSON for storage 314 - manifestJSON, err := json.Marshal(manifestRecord) 315 - if err != nil { 316 - return fmt.Errorf("failed to marshal manifest: %w", err) 317 - } 318 - 319 318 // Extract OCI annotations from manifest 320 319 var title, description, sourceURL, documentationURL, licenses, iconURL string 321 320 if manifestRecord.Annotations != nil { ··· 336 335 SchemaVersion: manifestRecord.SchemaVersion, 337 336 ConfigDigest: manifestRecord.Config.Digest, 338 337 ConfigSize: manifestRecord.Config.Size, 339 - RawManifest: string(manifestJSON), 340 338 HoldEndpoint: manifestRecord.HoldEndpoint, 341 339 CreatedAt: manifestRecord.CreatedAt, 342 340 Title: title, ··· 407 405 Digest: tagRecord.ManifestDigest, 408 406 CreatedAt: tagRecord.UpdatedAt, 409 407 }) 408 + } 409 + 410 + // processStar processes a star commit event 411 + func (w *Worker) processStar(commit *CommitEvent) error { 412 + // Resolve and upsert the user who starred (starrer) 413 + if err := w.ensureUser(context.Background(), commit.DID); err != nil { 414 + return fmt.Errorf("failed to ensure user: %w", err) 415 + } 416 + 417 + if commit.Operation == "delete" { 418 + // Unstar - parse the record to get the subject (owner DID and repository) 419 + var starRecord atproto.StarRecord 420 + if commit.Record != nil { 421 + recordBytes, err := json.Marshal(commit.Record) 422 + if err != nil { 423 + return fmt.Errorf("failed to marshal record: %w", err) 424 + } 425 + if err := json.Unmarshal(recordBytes, &starRecord); err != nil { 426 + return fmt.Errorf("failed to unmarshal star: %w", err) 427 + } 428 + 429 + // Decrement star count 430 + return db.DecrementStarCount(w.db, starRecord.Subject.DID, starRecord.Subject.Repository) 431 + } 432 + // If no record data, we can't determine what was unstarred 433 + return nil 434 + } 435 + 436 + // Parse star record 437 + var starRecord atproto.StarRecord 438 + if commit.Record != nil { 439 + recordBytes, err := json.Marshal(commit.Record) 440 + if err != nil { 441 + return fmt.Errorf("failed to marshal record: %w", err) 442 + } 443 + if err := json.Unmarshal(recordBytes, &starRecord); err != nil { 444 + return fmt.Errorf("failed to unmarshal star: %w", err) 445 + } 446 + } else { 447 + return nil 448 + } 449 + 450 + // Increment star count for the repository being starred 451 + return db.IncrementStarCount(w.db, starRecord.Subject.DID, starRecord.Subject.Repository) 410 452 } 411 453 412 454 // JetstreamEvent represents a Jetstream event
-3
pkg/appview/templates/components/modal.html
··· 25 25 </time> 26 26 </div> 27 27 </div> 28 - 29 - <h3>Raw Manifest</h3> 30 - <pre class="manifest-json"><code>{{ .RawManifest }}</code></pre> 31 28 </div> 32 29 </div> 33 30 {{ end }}
+46 -43
pkg/atproto/lexicon.go
··· 22 22 23 23 // SailorProfileCollection is the collection name for user profiles 24 24 SailorProfileCollection = "io.atcr.sailor.profile" 25 + 26 + // StarCollection is the collection name for repository stars 27 + StarCollection = "io.atcr.sailor.star" 25 28 ) 26 29 27 30 // ManifestRecord represents a container image manifest stored in ATProto ··· 59 62 Subject *BlobReference `json:"subject,omitempty"` 60 63 61 64 // ManifestBlob is a reference to the manifest blob stored in ATProto blob storage 62 - // This is the new way of storing manifests (replaces RawManifest) 63 65 ManifestBlob *ATProtoBlobRef `json:"manifestBlob,omitempty"` 64 66 65 - // RawManifest stores the original manifest bytes (base64 encoded) - DEPRECATED 66 - // Kept for backward compatibility with old records 67 - // New records should use ManifestBlob instead 68 - RawManifest string `json:"rawManifest,omitempty"` 69 - 70 67 // CreatedAt timestamp 71 68 CreatedAt time.Time `json:"createdAt"` 72 69 } ··· 114 111 SchemaVersion: ociData.SchemaVersion, 115 112 Annotations: ociData.Annotations, 116 113 // ManifestBlob will be set by the caller after uploading to blob storage 117 - // RawManifest no longer stored for new records (backward compat only) 118 114 CreatedAt: time.Now(), 119 115 } 120 116 ··· 143 139 return record, nil 144 140 } 145 141 146 - // ToOCIManifest converts the manifest record back to OCI manifest JSON 147 - // This should NOT be used directly - use manifest_store.Get() which downloads the blob 148 - // This is kept for backward compatibility only 149 - func (m *ManifestRecord) ToOCIManifest() ([]byte, error) { 150 - // New records: ManifestBlob reference (blob downloaded separately by manifest store) 151 - // This function should not be called for new records - it's a fallback only 152 - 153 - // Backward compatibility: If we have the raw manifest stored, return it 154 - if m.RawManifest != "" { 155 - rawBytes, err := base64.StdEncoding.DecodeString(m.RawManifest) 156 - if err != nil { 157 - return nil, err 158 - } 159 - return rawBytes, nil 160 - } 161 - 162 - // Last resort: reconstruct from fields (will have different digest!) 163 - // This should only happen for very old records 164 - ociManifest := map[string]any{ 165 - "schemaVersion": m.SchemaVersion, 166 - "mediaType": m.MediaType, 167 - "config": m.Config, 168 - "layers": m.Layers, 169 - } 170 - 171 - if m.Subject != nil { 172 - ociManifest["subject"] = m.Subject 173 - } 174 - 175 - if len(m.Annotations) > 0 { 176 - ociManifest["annotations"] = m.Annotations 177 - } 178 - 179 - return json.Marshal(ociManifest) 180 - } 181 - 182 142 // TagRecord represents a tag pointing to a manifest 183 143 type TagRecord struct { 184 144 // Type should be "io.atcr.tag" ··· 299 259 UpdatedAt: now, 300 260 } 301 261 } 262 + 263 + // StarSubject represents the subject of a star (the repository being starred) 264 + type StarSubject struct { 265 + // DID is the DID of the repository owner 266 + DID string `json:"did"` 267 + 268 + // Repository is the name of the repository 269 + Repository string `json:"repository"` 270 + } 271 + 272 + // StarRecord represents a user starring a repository 273 + // Stored in the starrer's PDS (like Bluesky likes) 274 + type StarRecord struct { 275 + // Type should be "io.atcr.sailor.star" 276 + Type string `json:"$type"` 277 + 278 + // Subject is the repository being starred 279 + Subject StarSubject `json:"subject"` 280 + 281 + // CreatedAt timestamp 282 + CreatedAt time.Time `json:"createdAt"` 283 + } 284 + 285 + // NewStarRecord creates a new star record 286 + func NewStarRecord(ownerDID, repository string) *StarRecord { 287 + return &StarRecord{ 288 + Type: StarCollection, 289 + Subject: StarSubject{ 290 + DID: ownerDID, 291 + Repository: repository, 292 + }, 293 + CreatedAt: time.Now(), 294 + } 295 + } 296 + 297 + // StarRecordKey generates a record key for a star 298 + // Uses a simple hash to ensure uniqueness and prevent duplicate stars 299 + func StarRecordKey(ownerDID, repository string) string { 300 + // Use base64 encoding of "ownerDID/repository" as the record key 301 + // This is deterministic and prevents duplicate stars 302 + combined := ownerDID + "/" + repository 303 + return base64.RawURLEncoding.EncodeToString([]byte(combined)) 304 + }
+17 -8
pkg/atproto/manifest_store.go
··· 11 11 "github.com/opencontainers/go-digest" 12 12 ) 13 13 14 + // DatabaseMetrics interface for tracking push counts 15 + type DatabaseMetrics interface { 16 + IncrementPushCount(did, repository string) error 17 + } 18 + 14 19 // ManifestStore implements distribution.ManifestService 15 20 // It stores manifests in ATProto as records 16 21 type ManifestStore struct { ··· 20 25 did string // User's DID for cache key 21 26 lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull) 22 27 blobStore distribution.BlobStore // Blob store for fetching config during push 28 + database DatabaseMetrics // Database for metrics tracking 23 29 } 24 30 25 31 // NewManifestStore creates a new ATProto-backed manifest store 26 - func NewManifestStore(client *Client, repository string, holdEndpoint string, did string, blobStore distribution.BlobStore) *ManifestStore { 32 + func NewManifestStore(client *Client, repository string, holdEndpoint string, did string, blobStore distribution.BlobStore, database DatabaseMetrics) *ManifestStore { 27 33 return &ManifestStore{ 28 34 client: client, 29 35 repository: repository, 30 36 holdEndpoint: holdEndpoint, 31 37 did: did, 32 38 blobStore: blobStore, 39 + database: database, 33 40 } 34 41 } 35 42 ··· 75 82 if err != nil { 76 83 return nil, fmt.Errorf("failed to download manifest blob: %w", err) 77 84 } 78 - } else { 79 - // Backward compatibility: Use ToOCIManifest for old records 80 - ociManifest, err = manifestRecord.ToOCIManifest() 81 - if err != nil { 82 - return nil, fmt.Errorf("failed to convert to OCI manifest: %w", err) 83 - } 84 85 } 85 - 86 86 // Parse the manifest based on media type 87 87 // For now, we'll return the raw bytes wrapped in a manifest object 88 88 // In a full implementation, you'd use distribution's manifest parsing ··· 143 143 _, err = s.client.PutRecord(ctx, ManifestCollection, rkey, manifestRecord) 144 144 if err != nil { 145 145 return "", fmt.Errorf("failed to store manifest record in ATProto: %w", err) 146 + } 147 + 148 + // Track push count (increment asynchronously to avoid blocking the response) 149 + if s.database != nil { 150 + go func() { 151 + if err := s.database.IncrementPushCount(s.did, s.repository); err != nil { 152 + fmt.Printf("WARNING: Failed to increment push count for %s/%s: %v\n", s.did, s.repository, err) 153 + } 154 + }() 146 155 } 147 156 148 157 // Also handle tag if specified
+15 -1
pkg/middleware/registry.go
··· 23 23 // Global refresher instance (set by main.go) 24 24 var globalRefresher *oauth.Refresher 25 25 26 + // Global database instance (set by main.go for pull tracking) 27 + var globalDatabase interface { 28 + IncrementPullCount(did, repository string) error 29 + IncrementPushCount(did, repository string) error 30 + } 31 + 26 32 // SetGlobalRefresher sets the global OAuth refresher instance 27 33 func SetGlobalRefresher(refresher *oauth.Refresher) { 28 34 globalRefresher = refresher 35 + } 36 + 37 + // SetGlobalDatabase sets the global database instance for metrics tracking 38 + func SetGlobalDatabase(database interface { 39 + IncrementPullCount(did, repository string) error 40 + IncrementPushCount(did, repository string) error 41 + }) { 42 + globalDatabase = database 29 43 } 30 44 31 45 func init() { ··· 169 183 // Create routing repository - routes manifests to ATProto, blobs to hold service 170 184 // The registry is stateless - no local storage is used 171 185 // Pass storage endpoint and DID as parameters (can't use context as it gets lost) 172 - routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did) 186 + routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did, globalDatabase) 173 187 174 188 // Cache the repository 175 189 nr.repositories.Store(cacheKey, routingRepo)
+16 -3
pkg/storage/proxy_blob_store.go
··· 32 32 storageEndpoint string 33 33 httpClient *http.Client 34 34 did string 35 + database DatabaseMetrics 36 + repository string 35 37 } 36 38 37 39 // NewProxyBlobStore creates a new proxy blob store 38 - func NewProxyBlobStore(storageEndpoint, did string) *ProxyBlobStore { 39 - fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, did=%s\n", storageEndpoint, did) 40 + func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, repository string) *ProxyBlobStore { 41 + fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, did=%s, repo=%s\n", storageEndpoint, did, repository) 40 42 return &ProxyBlobStore{ 41 43 storageEndpoint: storageEndpoint, 42 44 httpClient: &http.Client{ ··· 49 51 IdleConnTimeout: 90 * time.Second, 50 52 }, 51 53 }, 52 - did: did, 54 + did: did, 55 + database: database, 56 + repository: repository, 53 57 } 54 58 } 55 59 ··· 177 181 url, err := p.getDownloadURL(ctx, dgst) 178 182 if err != nil { 179 183 return err 184 + } 185 + 186 + // Track pull count (increment asynchronously to avoid blocking the response) 187 + if p.database != nil && p.repository != "" { 188 + go func() { 189 + if err := p.database.IncrementPullCount(p.did, p.repository); err != nil { 190 + fmt.Printf("WARNING: Failed to increment pull count for %s/%s: %v\n", p.did, p.repository, err) 191 + } 192 + }() 180 193 } 181 194 182 195 // Redirect to presigned URL
+11 -2
pkg/storage/routing_repository.go
··· 9 9 "github.com/distribution/distribution/v3" 10 10 ) 11 11 12 + // DatabaseMetrics interface for tracking pull/push counts 13 + type DatabaseMetrics interface { 14 + IncrementPullCount(did, repository string) error 15 + IncrementPushCount(did, repository string) error 16 + } 17 + 12 18 // RoutingRepository routes manifests to ATProto and blobs to external hold service 13 19 // The registry (AppView) is stateless and NEVER stores blobs locally 14 20 type RoutingRepository struct { ··· 19 25 did string // User's DID for authorization 20 26 manifestStore *atproto.ManifestStore // Cached manifest store instance 21 27 blobStore *ProxyBlobStore // Cached blob store instance 28 + database DatabaseMetrics // Database for metrics tracking 22 29 } 23 30 24 31 // NewRoutingRepository creates a new routing repository ··· 28 35 repoName string, 29 36 storageEndpoint string, 30 37 did string, 38 + database DatabaseMetrics, 31 39 ) *RoutingRepository { 32 40 return &RoutingRepository{ 33 41 Repository: baseRepo, ··· 35 43 repositoryName: repoName, 36 44 storageEndpoint: storageEndpoint, 37 45 did: did, 46 + database: database, 38 47 } 39 48 } 40 49 ··· 45 54 // Ensure blob store is created first (needed for label extraction during push) 46 55 blobStore := r.Blobs(ctx) 47 56 48 - r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, r.did, blobStore) 57 + r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, r.did, blobStore, r.database) 49 58 } 50 59 51 60 // After any manifest operation, cache the hold endpoint for blob fetches ··· 94 103 } 95 104 96 105 // Create and cache proxy blob store 97 - r.blobStore = NewProxyBlobStore(holdEndpoint, r.did) 106 + r.blobStore = NewProxyBlobStore(holdEndpoint, r.did, r.database, r.repositoryName) 98 107 return r.blobStore 99 108 } 100 109
+5 -5
test-registry.sh
··· 1 1 #!/bin/bash 2 2 3 - # ATCR Registry Test Script 3 + # ATCR AppView Test Script 4 4 # Tests various registry operations with ATProto storage 5 5 6 6 # Configuration ··· 341 341 log_test "Check ATProto records in logs" 342 342 343 343 log_info "Recent manifest PUT operations:" 344 - docker logs atcr-registry 2>&1 | grep "Manifests()" | tail -5 || log_info "No manifest logs found" 344 + docker logs atcr-appview 2>&1 | grep "Manifests()" | tail -5 || log_info "No manifest logs found" 345 345 346 346 log_info "Recent tag operations:" 347 - docker logs atcr-registry 2>&1 | grep "debian_12-slim\|debian_latest\|alpine_latest" | tail -10 || log_info "No tag logs found" 347 + docker logs atcr-appview 2>&1 | grep "debian_12-slim\|debian_latest\|alpine_latest" | tail -10 || log_info "No tag logs found" 348 348 349 349 log_info "Using cached access token:" 350 - docker logs atcr-registry 2>&1 | grep "Using cached access token" | tail -3 || log_info "No token cache logs found" 350 + docker logs atcr-appview 2>&1 | grep "Using cached access token" | tail -3 || log_info "No token cache logs found" 351 351 352 352 log_success "Log check complete" 353 353 return 0 ··· 367 367 main() { 368 368 echo -e "${GREEN}" 369 369 echo "╔═══════════════════════════════════════╗" 370 - echo "║ ATCR Registry Test Suite ║" 370 + echo "║ ATCR AppView Test Suite ║" 371 371 echo "║ Testing ATProto + OCI Registry ║" 372 372 echo "╚═══════════════════════════════════════╝" 373 373 echo -e "${NC}"