A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
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}"