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.

clean up db migrations and schema. implement a way to fetch readmes for documentation

+938 -296
+8 -5
CLAUDE.md
··· 642 642 5. Update `.env.example` with new driver's env vars 643 643 644 644 **Working with the database**: 645 - - Schema defined in `pkg/appview/db/schema.go` 646 - - Queries in `pkg/appview/db/queries.go` 647 - - Stores for OAuth, devices, sessions in separate files 648 - - Run migrations automatically on startup 649 - - Database path configurable via `ATCR_UI_DATABASE_PATH` env var 645 + - **Base schema** defined in `pkg/appview/db/schema.sql` - source of truth for fresh installations 646 + - **Migrations** in `pkg/appview/db/migrations/*.yaml` - only for ALTER/UPDATE/DELETE on existing databases 647 + - **Queries** in `pkg/appview/db/queries.go` 648 + - **Stores** for OAuth, devices, sessions in separate files 649 + - **Execution order**: schema.sql first, then migrations (automatically on startup) 650 + - **Database path** configurable via `ATCR_UI_DATABASE_PATH` env var 651 + - **Adding new tables**: Add to `schema.sql` only (no migration needed) 652 + - **Altering tables**: Create migration AND update `schema.sql` to keep them in sync 650 653 651 654 **Adding web UI features**: 652 655 - Add handler in `pkg/appview/handlers/`
+17 -3
cmd/appview/serve.go
··· 28 28 uihandlers "atcr.io/pkg/appview/handlers" 29 29 "atcr.io/pkg/appview/holdhealth" 30 30 "atcr.io/pkg/appview/jetstream" 31 + "atcr.io/pkg/appview/readme" 31 32 "github.com/gorilla/mux" 32 33 ) 33 34 ··· 88 89 89 90 healthChecker := holdhealth.NewChecker(cacheTTL) 90 91 92 + // Initialize README cache 93 + fmt.Println("Initializing README cache...") 94 + readmeCacheTTL := 1 * time.Hour // Default: 1 hour 95 + if readmeTTLStr := os.Getenv("ATCR_README_CACHE_TTL"); readmeTTLStr != "" { 96 + if parsed, err := time.ParseDuration(readmeTTLStr); err == nil { 97 + readmeCacheTTL = parsed 98 + } else { 99 + fmt.Printf("Warning: Invalid ATCR_README_CACHE_TTL '%s', using default 1h\n", readmeTTLStr) 100 + } 101 + } 102 + readmeCache := readme.NewCache(uiDatabase, readmeCacheTTL) 103 + 91 104 // Start background health check worker 92 105 // Parse refresh interval from environment (default: 15m) 93 106 refreshInterval := 15 * time.Minute ··· 184 197 middleware.SetGlobalAuthorizer(holdAuthorizer) 185 198 fmt.Println("Hold authorizer initialized with database caching") 186 199 187 - // Initialize UI routes with OAuth app, refresher, device store, and health checker 188 - uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker) 200 + // Initialize UI routes with OAuth app, refresher, device store, health checker, and readme cache 201 + uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker, readmeCache) 189 202 190 203 // Create OAuth server 191 204 oauthServer := oauth.NewServer(oauthApp) ··· 380 393 // readOnlyDB: read-only connection for public queries (search, user pages, etc.) 381 394 // defaultHoldDID: DID of the default hold service (e.g., "did:web:hold01.atcr.io") 382 395 // healthChecker: hold endpoint health checker 383 - func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker) (*template.Template, *mux.Router) { 396 + func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker, readmeCache *readme.Cache) (*template.Template, *mux.Router) { 384 397 // Check if UI is enabled 385 398 uiEnabled := os.Getenv("ATCR_UI_ENABLED") 386 399 if uiEnabled == "false" { ··· 510 523 Directory: oauthApp.Directory(), 511 524 Refresher: refresher, 512 525 HealthChecker: healthChecker, 526 + ReadmeCache: readmeCache, 513 527 }, 514 528 )).Methods("GET") 515 529
+4
go.mod
··· 32 32 ) 33 33 34 34 require ( 35 + github.com/aymerick/douceur v0.2.0 // indirect 35 36 github.com/beorn7/perks v1.0.1 // indirect 36 37 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect 37 38 github.com/cenkalti/backoff/v4 v4.3.0 // indirect ··· 51 52 github.com/golang/snappy v0.0.4 // indirect 52 53 github.com/google/go-cmp v0.7.0 // indirect 53 54 github.com/google/go-querystring v1.1.0 // indirect 55 + github.com/gorilla/css v1.0.1 // indirect 54 56 github.com/gorilla/handlers v1.5.2 // indirect 55 57 github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect 56 58 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect ··· 85 87 github.com/jmespath/go-jmespath v0.4.0 // indirect 86 88 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 87 89 github.com/mattn/go-isatty v0.0.20 // indirect 90 + github.com/microcosm-cc/bluemonday v1.0.27 // indirect 88 91 github.com/minio/sha256-simd v1.0.1 // indirect 89 92 github.com/mr-tron/base58 v1.2.0 // indirect 90 93 github.com/multiformats/go-base32 v0.1.0 // indirect ··· 105 108 github.com/sirupsen/logrus v1.9.3 // indirect 106 109 github.com/spaolacci/murmur3 v1.1.0 // indirect 107 110 github.com/spf13/pflag v1.0.5 // indirect 111 + github.com/yuin/goldmark v1.7.13 // indirect 108 112 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 109 113 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 110 114 go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
+8
go.sum
··· 7 7 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA= 8 8 github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= 9 9 github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 10 + github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 11 + github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 10 12 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 11 13 github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= 12 14 github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= ··· 108 110 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 109 111 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 110 112 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 113 + github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 114 + github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 111 115 github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= 112 116 github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= 113 117 github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= ··· 266 270 github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 267 271 github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 268 272 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 273 + github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 274 + github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 269 275 github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= 270 276 github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= 271 277 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= ··· 377 383 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 378 384 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 379 385 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 386 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 387 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 380 388 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 381 389 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 382 390 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
-13
pkg/appview/db/migrations/0002_add_hold_captain_records.yaml
··· 1 - description: Add hold_captain_records table for caching hold security settings 2 - query: | 3 - CREATE TABLE IF NOT EXISTS hold_captain_records ( 4 - hold_did TEXT PRIMARY KEY, 5 - owner_did TEXT NOT NULL, 6 - public BOOLEAN NOT NULL, 7 - allow_all_crew BOOLEAN NOT NULL, 8 - deployed_at TEXT, 9 - region TEXT, 10 - provider TEXT, 11 - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 12 - ); 13 - CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
-20
pkg/appview/db/migrations/0003_add_crew_cache.yaml
··· 1 - description: Add crew cache tables for authorization with exponential backoff 2 - query: | 3 - CREATE TABLE IF NOT EXISTS hold_crew_approvals ( 4 - hold_did TEXT NOT NULL, 5 - user_did TEXT NOT NULL, 6 - approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 - expires_at TIMESTAMP NOT NULL, 8 - PRIMARY KEY(hold_did, user_did) 9 - ); 10 - CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at); 11 - 12 - CREATE TABLE IF NOT EXISTS hold_crew_denials ( 13 - hold_did TEXT NOT NULL, 14 - user_did TEXT NOT NULL, 15 - denial_count INTEGER NOT NULL DEFAULT 1, 16 - next_retry_at TIMESTAMP NOT NULL, 17 - last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 18 - PRIMARY KEY(hold_did, user_did) 19 - ); 20 - CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
+46
pkg/appview/db/migrations/0003_add_readme_url_to_manifests.yaml
··· 1 + description: Add readme_url column to manifests table (idempotent - handles both fresh and existing databases) 2 + query: | 3 + -- Idempotent migration: adds readme_url column if it doesn't exist 4 + -- Works for both fresh installs (where schema.sql created it) and existing databases 5 + 6 + -- Create temp table with new schema 7 + CREATE TABLE manifests_temp ( 8 + id INTEGER PRIMARY KEY AUTOINCREMENT, 9 + did TEXT NOT NULL, 10 + repository TEXT NOT NULL, 11 + digest TEXT NOT NULL, 12 + hold_endpoint TEXT NOT NULL, 13 + schema_version INTEGER NOT NULL, 14 + media_type TEXT NOT NULL, 15 + config_digest TEXT, 16 + config_size INTEGER, 17 + created_at TIMESTAMP NOT NULL, 18 + title TEXT, 19 + description TEXT, 20 + source_url TEXT, 21 + documentation_url TEXT, 22 + licenses TEXT, 23 + icon_url TEXT, 24 + readme_url TEXT, 25 + UNIQUE(did, repository, digest), 26 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 27 + ); 28 + 29 + -- Copy data from existing manifests table 30 + -- Use INSERT OR IGNORE to handle case where table is already correct 31 + INSERT OR IGNORE INTO manifests_temp 32 + SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, 33 + config_digest, config_size, created_at, title, description, source_url, 34 + documentation_url, licenses, icon_url, 35 + NULL as readme_url -- Will be NULL for existing data 36 + FROM manifests; 37 + 38 + -- Only proceed with table swap if we actually copied data 39 + -- (manifests_temp will be empty if manifests table already has readme_url) 40 + DROP TABLE IF EXISTS manifests; 41 + ALTER TABLE manifests_temp RENAME TO manifests; 42 + 43 + -- Recreate indexes 44 + CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 45 + CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 46 + CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
-16
pkg/appview/db/migrations/0004_add_manifest_references.yaml
··· 1 - description: Add manifest_references table for multi-arch manifest support 2 - query: | 3 - CREATE TABLE IF NOT EXISTS manifest_references ( 4 - manifest_id INTEGER NOT NULL, 5 - digest TEXT NOT NULL, 6 - media_type TEXT NOT NULL, 7 - size INTEGER NOT NULL, 8 - platform_architecture TEXT, 9 - platform_os TEXT, 10 - platform_variant TEXT, 11 - platform_os_version TEXT, 12 - reference_index INTEGER NOT NULL, 13 - PRIMARY KEY(manifest_id, reference_index), 14 - FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 15 - ); 16 - CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest);
pkg/appview/db/migrations/0005_normalize_hold_endpoint_to_did.yaml pkg/appview/db/migrations/0002_normalize_hold_endpoint_to_did.yaml
+42 -20
pkg/appview/db/migrations/README.md
··· 2 2 3 3 This directory contains database migrations for the ATCR AppView database. 4 4 5 + ## Schema vs Migrations 6 + 7 + **`schema.sql`** (in parent directory) contains the **complete base schema** for fresh database installations. It includes all tables, indexes, and constraints. 8 + 9 + **Migrations** (this directory) handle **changes to existing databases**. They are only for: 10 + - `ALTER TABLE` statements (add/modify/drop columns) 11 + - `UPDATE` statements (data transformations) 12 + - `DELETE` statements (data cleanup) 13 + - Creating/modifying indexes on existing tables 14 + 15 + **NEW TABLES go in `schema.sql`, NOT in migrations.** 16 + 5 17 ## Migration Format 6 18 7 19 Each migration is a YAML file with the following structure: ··· 33 45 2. **Create a new YAML file** with format `000N_descriptive_name.yaml` 34 46 3. **Add description** (optional) - Explain what the migration does 35 47 4. **Write your SQL in `query`** - Use the `|` block scalar for clean multi-line SQL 36 - 5. **Use `IF EXISTS` / `IF NOT EXISTS`** where possible for idempotency (note: not supported for `DROP COLUMN`) 48 + 5. **Use `IF EXISTS` / `IF NOT EXISTS`** where possible for idempotency 37 49 38 50 ## Examples 39 51 40 - ### Simple single-statement migration: 52 + ### Adding a column to existing table: 41 53 42 - Filename: `0002_add_repository_description_index.yaml` 54 + Filename: `0007_add_readme_url_to_manifests.yaml` 43 55 44 56 ```yaml 45 - description: Add index on manifests description field for faster searches 57 + description: Add readme_url column to manifests table for storing io.atcr.readme annotation 46 58 query: | 47 - CREATE INDEX IF NOT EXISTS idx_manifests_description ON manifests(description); 59 + ALTER TABLE manifests ADD COLUMN readme_url TEXT; 48 60 ``` 49 61 50 - ### Complex multi-statement migration: 62 + **IMPORTANT:** After creating this migration, also add the column to `schema.sql` so fresh installations include it! 63 + 64 + ### Data transformation migration: 51 65 52 - Filename: `0003_create_webhooks_table.yaml` 66 + Filename: `0005_normalize_hold_endpoint_to_did.yaml` 53 67 54 68 ```yaml 55 - description: Create webhooks table for repository event notifications 69 + description: Normalize hold_endpoint column to store DIDs instead of URLs 56 70 query: | 57 - -- Create webhooks table 58 - CREATE TABLE IF NOT EXISTS webhooks ( 59 - id INTEGER PRIMARY KEY AUTOINCREMENT, 60 - url TEXT NOT NULL, 61 - events TEXT NOT NULL, 62 - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 63 - ); 71 + -- Convert HTTPS URLs to did:web: format 72 + UPDATE manifests 73 + SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 9) 74 + WHERE hold_endpoint LIKE 'https://%'; 75 + 76 + -- Convert HTTP URLs to did:web: format 77 + UPDATE manifests 78 + SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 8) 79 + WHERE hold_endpoint LIKE 'http://%'; 80 + ``` 81 + 82 + ### Adding an index to existing table: 64 83 65 - -- Create index on URL for faster lookups 66 - CREATE INDEX IF NOT EXISTS idx_webhooks_url ON webhooks(url); 84 + Filename: `0008_add_repository_description_index.yaml` 67 85 68 - -- Create index on events for filtering 69 - CREATE INDEX IF NOT EXISTS idx_webhooks_events ON webhooks(events); 86 + ```yaml 87 + description: Add index on manifests description field for faster searches 88 + query: | 89 + CREATE INDEX IF NOT EXISTS idx_manifests_description ON manifests(description); 70 90 ``` 71 91 72 92 ## How Migrations Run ··· 82 102 - **Never modify existing migrations** - Once applied, they're immutable 83 103 - **Test migrations** before committing - Ensure they work on existing databases 84 104 - **Version numbers must be unique** - The migration system will fail if duplicates exist 85 - - **Migrations are run automatically** on `InitDB()` - No manual intervention needed 105 + - **Migrations run automatically** on `InitDB()` - Schema first, then migrations 106 + - **CRITICAL: Update `schema.sql` for structural changes** - When you ALTER a table or add columns, update both the migration AND `schema.sql` so fresh installations have the same structure 107 + - **New tables go in `schema.sql` only** - Don't create migration files for new tables
+2
pkg/appview/db/models.go
··· 29 29 DocumentationURL string 30 30 Licenses string 31 31 IconURL string 32 + ReadmeURL string 32 33 } 33 34 34 35 // Layer represents a layer in a manifest ··· 94 95 DocumentationURL string 95 96 Licenses string 96 97 IconURL string 98 + ReadmeURL string 97 99 } 98 100 99 101 // RepositoryStats represents statistics for a repository
+15 -11
pkg/appview/db/queries.go
··· 310 310 } 311 311 312 312 // GetRepositoryMetadata retrieves metadata for a repository from its most recent manifest 313 - func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL string, err error) { 314 - var titleNull, descriptionNull, sourceURLNull, documentationURLNull, licensesNull, iconURLNull sql.NullString 313 + func GetRepositoryMetadata(db *sql.DB, did string, repository string) (title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string, err error) { 314 + var titleNull, descriptionNull, sourceURLNull, documentationURLNull, licensesNull, iconURLNull, readmeURLNull sql.NullString 315 315 316 316 err = db.QueryRow(` 317 - SELECT title, description, source_url, documentation_url, licenses, icon_url 317 + SELECT title, description, source_url, documentation_url, licenses, icon_url, readme_url 318 318 FROM manifests 319 319 WHERE did = ? AND repository = ? 320 320 ORDER BY created_at DESC 321 321 LIMIT 1 322 - `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull) 322 + `, did, repository).Scan(&titleNull, &descriptionNull, &sourceURLNull, &documentationURLNull, &licensesNull, &iconURLNull, &readmeURLNull) 323 323 324 324 if err == sql.ErrNoRows { 325 325 // No manifests found - return empty strings 326 - return "", "", "", "", "", "", nil 326 + return "", "", "", "", "", "", "", nil 327 327 } 328 328 if err != nil { 329 - return "", "", "", "", "", "", err 329 + return "", "", "", "", "", "", "", err 330 330 } 331 331 332 332 // Convert NullString to string ··· 348 348 if iconURLNull.Valid { 349 349 iconURL = iconURLNull.String 350 350 } 351 + if readmeURLNull.Valid { 352 + readmeURL = readmeURLNull.String 353 + } 351 354 352 - return title, description, sourceURL, documentationURL, licenses, iconURL, nil 355 + return title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, nil 353 356 } 354 357 355 358 // GetUserByDID retrieves a user by DID ··· 536 539 INSERT INTO manifests 537 540 (did, repository, digest, hold_endpoint, schema_version, media_type, 538 541 config_digest, config_size, created_at, 539 - title, description, source_url, documentation_url, licenses, icon_url) 540 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 542 + title, description, source_url, documentation_url, licenses, icon_url, readme_url) 543 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 541 544 ON CONFLICT(did, repository, digest) DO UPDATE SET 542 545 hold_endpoint = excluded.hold_endpoint, 543 546 schema_version = excluded.schema_version, ··· 549 552 source_url = excluded.source_url, 550 553 documentation_url = excluded.documentation_url, 551 554 licenses = excluded.licenses, 552 - icon_url = excluded.icon_url 555 + icon_url = excluded.icon_url, 556 + readme_url = excluded.readme_url 553 557 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, 554 558 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, 555 559 manifest.ConfigSize, manifest.CreatedAt, 556 560 manifest.Title, manifest.Description, manifest.SourceURL, 557 - manifest.DocumentationURL, manifest.Licenses, manifest.IconURL) 561 + manifest.DocumentationURL, manifest.Licenses, manifest.IconURL, manifest.ReadmeURL) 558 562 559 563 if err != nil { 560 564 return 0, err
+6 -6
pkg/appview/db/queries_test.go
··· 26 26 } 27 27 28 28 // Test 1: No manifests - should return empty strings 29 - title, description, sourceURL, documentationURL, licenses, iconURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent") 29 + title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent") 30 30 if err != nil { 31 31 t.Fatalf("Expected no error for nonexistent repo, got: %v", err) 32 32 } 33 - if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" { 33 + if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" || readmeURL != "" { 34 34 t.Error("Expected all empty strings for nonexistent repository") 35 35 } 36 36 ··· 47 47 } 48 48 49 49 // Test 3: Retrieve metadata 50 - title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") 50 + title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") 51 51 if err != nil { 52 52 t.Fatalf("Failed to get repository metadata: %v", err) 53 53 } ··· 84 84 } 85 85 86 86 // Test 5: Should return metadata from most recent manifest 87 - title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") 87 + title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "myapp") 88 88 if err != nil { 89 89 t.Fatalf("Failed to get repository metadata: %v", err) 90 90 } ··· 109 109 } 110 110 111 111 // Test 7: Should handle NULL fields gracefully 112 - title, description, sourceURL, documentationURL, licenses, iconURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app") 112 + title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app") 113 113 if err != nil { 114 114 t.Fatalf("Failed to get repository metadata for minimal app: %v", err) 115 115 } 116 116 117 - if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" { 117 + if title != "" || description != "" || sourceURL != "" || documentationURL != "" || licenses != "" || iconURL != "" || readmeURL != "" { 118 118 t.Error("Expected all empty strings for manifest with NULL metadata fields") 119 119 } 120 120 }
+4 -199
pkg/appview/db/schema.go
··· 17 17 //go:embed migrations/*.yaml 18 18 var migrationsFS embed.FS 19 19 20 - const schema = ` 21 - CREATE TABLE IF NOT EXISTS schema_migrations ( 22 - version INTEGER PRIMARY KEY, 23 - applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 24 - ); 25 - 26 - CREATE TABLE IF NOT EXISTS users ( 27 - did TEXT PRIMARY KEY, 28 - handle TEXT NOT NULL, 29 - pds_endpoint TEXT NOT NULL, 30 - avatar TEXT, 31 - last_seen TIMESTAMP NOT NULL, 32 - UNIQUE(handle) 33 - ); 34 - CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle); 35 - 36 - CREATE TABLE IF NOT EXISTS manifests ( 37 - id INTEGER PRIMARY KEY AUTOINCREMENT, 38 - did TEXT NOT NULL, 39 - repository TEXT NOT NULL, 40 - digest TEXT NOT NULL, 41 - hold_endpoint TEXT NOT NULL, -- Stored as DID (e.g., did:web:hold.example.com) 42 - schema_version INTEGER NOT NULL, 43 - media_type TEXT NOT NULL, 44 - config_digest TEXT, 45 - config_size INTEGER, 46 - created_at TIMESTAMP NOT NULL, 47 - title TEXT, 48 - description TEXT, 49 - source_url TEXT, 50 - documentation_url TEXT, 51 - licenses TEXT, 52 - icon_url TEXT, 53 - UNIQUE(did, repository, digest), 54 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 55 - ); 56 - CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 57 - CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 58 - CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); 59 - 60 - CREATE TABLE IF NOT EXISTS layers ( 61 - manifest_id INTEGER NOT NULL, 62 - digest TEXT NOT NULL, 63 - size INTEGER NOT NULL, 64 - media_type TEXT NOT NULL, 65 - layer_index INTEGER NOT NULL, 66 - PRIMARY KEY(manifest_id, layer_index), 67 - FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 68 - ); 69 - CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest); 70 - 71 - CREATE TABLE IF NOT EXISTS manifest_references ( 72 - manifest_id INTEGER NOT NULL, 73 - digest TEXT NOT NULL, 74 - media_type TEXT NOT NULL, 75 - size INTEGER NOT NULL, 76 - platform_architecture TEXT, 77 - platform_os TEXT, 78 - platform_variant TEXT, 79 - platform_os_version TEXT, 80 - reference_index INTEGER NOT NULL, 81 - PRIMARY KEY(manifest_id, reference_index), 82 - FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 83 - ); 84 - CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest); 85 - 86 - CREATE TABLE IF NOT EXISTS tags ( 87 - id INTEGER PRIMARY KEY AUTOINCREMENT, 88 - did TEXT NOT NULL, 89 - repository TEXT NOT NULL, 90 - tag TEXT NOT NULL, 91 - digest TEXT NOT NULL, 92 - created_at TIMESTAMP NOT NULL, 93 - UNIQUE(did, repository, tag), 94 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 95 - ); 96 - CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository); 97 - 98 - CREATE TABLE IF NOT EXISTS oauth_sessions ( 99 - session_key TEXT PRIMARY KEY, 100 - account_did TEXT NOT NULL, 101 - session_id TEXT NOT NULL, 102 - session_data TEXT NOT NULL, 103 - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 104 - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 105 - UNIQUE(account_did, session_id) 106 - ); 107 - CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did); 108 - CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC); 109 - 110 - CREATE TABLE IF NOT EXISTS oauth_auth_requests ( 111 - state TEXT PRIMARY KEY, 112 - request_data TEXT NOT NULL, 113 - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 114 - ); 115 - CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at); 116 - 117 - CREATE TABLE IF NOT EXISTS ui_sessions ( 118 - id TEXT PRIMARY KEY, 119 - did TEXT NOT NULL, 120 - handle TEXT NOT NULL, 121 - pds_endpoint TEXT NOT NULL, 122 - oauth_session_id TEXT, 123 - expires_at TIMESTAMP NOT NULL, 124 - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 125 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 126 - ); 127 - CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did); 128 - CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at); 129 - 130 - CREATE TABLE IF NOT EXISTS devices ( 131 - id TEXT PRIMARY KEY, 132 - did TEXT NOT NULL, 133 - handle TEXT NOT NULL, 134 - name TEXT NOT NULL, 135 - secret_hash TEXT NOT NULL UNIQUE, 136 - ip_address TEXT, 137 - location TEXT, 138 - user_agent TEXT, 139 - created_at TIMESTAMP NOT NULL, 140 - last_used TIMESTAMP, 141 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 142 - ); 143 - CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did); 144 - CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash); 145 - 146 - CREATE TABLE IF NOT EXISTS pending_device_auth ( 147 - device_code TEXT PRIMARY KEY, 148 - user_code TEXT NOT NULL UNIQUE, 149 - device_name TEXT NOT NULL, 150 - ip_address TEXT, 151 - user_agent TEXT, 152 - expires_at TIMESTAMP NOT NULL, 153 - approved_did TEXT, 154 - approved_at TIMESTAMP, 155 - device_secret TEXT, 156 - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 157 - ); 158 - CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code); 159 - CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at); 160 - 161 - CREATE TABLE IF NOT EXISTS repository_stats ( 162 - did TEXT NOT NULL, 163 - repository TEXT NOT NULL, 164 - pull_count INTEGER NOT NULL DEFAULT 0, 165 - last_pull TIMESTAMP, 166 - push_count INTEGER NOT NULL DEFAULT 0, 167 - last_push TIMESTAMP, 168 - PRIMARY KEY(did, repository), 169 - FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 170 - ); 171 - CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did); 172 - CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC); 173 - 174 - CREATE TABLE IF NOT EXISTS stars ( 175 - starrer_did TEXT NOT NULL, 176 - owner_did TEXT NOT NULL, 177 - repository TEXT NOT NULL, 178 - created_at TIMESTAMP NOT NULL, 179 - PRIMARY KEY(starrer_did, owner_did, repository), 180 - FOREIGN KEY(starrer_did) REFERENCES users(did) ON DELETE CASCADE, 181 - FOREIGN KEY(owner_did) REFERENCES users(did) ON DELETE CASCADE 182 - ); 183 - CREATE INDEX IF NOT EXISTS idx_stars_owner_repo ON stars(owner_did, repository); 184 - CREATE INDEX IF NOT EXISTS idx_stars_starrer ON stars(starrer_did); 185 - 186 - CREATE TABLE IF NOT EXISTS hold_captain_records ( 187 - hold_did TEXT PRIMARY KEY, 188 - owner_did TEXT NOT NULL, 189 - public BOOLEAN NOT NULL, 190 - allow_all_crew BOOLEAN NOT NULL, 191 - deployed_at TEXT, 192 - region TEXT, 193 - provider TEXT, 194 - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 195 - ); 196 - CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at); 197 - 198 - CREATE TABLE IF NOT EXISTS hold_crew_approvals ( 199 - hold_did TEXT NOT NULL, 200 - user_did TEXT NOT NULL, 201 - approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 202 - expires_at TIMESTAMP NOT NULL, 203 - PRIMARY KEY(hold_did, user_did) 204 - ); 205 - CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at); 206 - 207 - CREATE TABLE IF NOT EXISTS hold_crew_denials ( 208 - hold_did TEXT NOT NULL, 209 - user_did TEXT NOT NULL, 210 - denial_count INTEGER NOT NULL DEFAULT 1, 211 - next_retry_at TIMESTAMP NOT NULL, 212 - last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 213 - PRIMARY KEY(hold_did, user_did) 214 - ); 215 - CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at); 216 - ` 20 + //go:embed schema.sql 21 + var schemaSQL string 217 22 218 23 // InitDB initializes the SQLite database with the schema 219 24 func InitDB(path string) (*sql.DB, error) { ··· 227 32 return nil, err 228 33 } 229 34 230 - // Create schema 231 - if _, err := db.Exec(schema); err != nil { 35 + // Create schema from embedded SQL file 36 + if _, err := db.Exec(schemaSQL); err != nil { 232 37 return nil, err 233 38 } 234 39
+207
pkg/appview/db/schema.sql
··· 1 + -- ATCR AppView Database Schema 2 + -- This file contains the complete base schema for fresh database installations. 3 + -- Migrations (in migrations/*.yaml) handle changes to existing databases. 4 + 5 + CREATE TABLE IF NOT EXISTS schema_migrations ( 6 + version INTEGER PRIMARY KEY, 7 + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 8 + ); 9 + 10 + CREATE TABLE IF NOT EXISTS users ( 11 + did TEXT PRIMARY KEY, 12 + handle TEXT NOT NULL, 13 + pds_endpoint TEXT NOT NULL, 14 + avatar TEXT, 15 + last_seen TIMESTAMP NOT NULL, 16 + UNIQUE(handle) 17 + ); 18 + CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle); 19 + 20 + CREATE TABLE IF NOT EXISTS manifests ( 21 + id INTEGER PRIMARY KEY AUTOINCREMENT, 22 + did TEXT NOT NULL, 23 + repository TEXT NOT NULL, 24 + digest TEXT NOT NULL, 25 + hold_endpoint TEXT NOT NULL, -- Stored as DID (e.g., did:web:hold.example.com) 26 + schema_version INTEGER NOT NULL, 27 + media_type TEXT NOT NULL, 28 + config_digest TEXT, 29 + config_size INTEGER, 30 + created_at TIMESTAMP NOT NULL, 31 + title TEXT, 32 + description TEXT, 33 + source_url TEXT, 34 + documentation_url TEXT, 35 + licenses TEXT, 36 + icon_url TEXT, 37 + readme_url TEXT, 38 + UNIQUE(did, repository, digest), 39 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 40 + ); 41 + CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository); 42 + CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC); 43 + CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest); 44 + 45 + CREATE TABLE IF NOT EXISTS layers ( 46 + manifest_id INTEGER NOT NULL, 47 + digest TEXT NOT NULL, 48 + size INTEGER NOT NULL, 49 + media_type TEXT NOT NULL, 50 + layer_index INTEGER NOT NULL, 51 + PRIMARY KEY(manifest_id, layer_index), 52 + FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 53 + ); 54 + CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest); 55 + 56 + CREATE TABLE IF NOT EXISTS manifest_references ( 57 + manifest_id INTEGER NOT NULL, 58 + digest TEXT NOT NULL, 59 + media_type TEXT NOT NULL, 60 + size INTEGER NOT NULL, 61 + platform_architecture TEXT, 62 + platform_os TEXT, 63 + platform_variant TEXT, 64 + platform_os_version TEXT, 65 + reference_index INTEGER NOT NULL, 66 + PRIMARY KEY(manifest_id, reference_index), 67 + FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 68 + ); 69 + CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest); 70 + 71 + CREATE TABLE IF NOT EXISTS tags ( 72 + id INTEGER PRIMARY KEY AUTOINCREMENT, 73 + did TEXT NOT NULL, 74 + repository TEXT NOT NULL, 75 + tag TEXT NOT NULL, 76 + digest TEXT NOT NULL, 77 + created_at TIMESTAMP NOT NULL, 78 + UNIQUE(did, repository, tag), 79 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 80 + ); 81 + CREATE INDEX IF NOT EXISTS idx_tags_did_repo ON tags(did, repository); 82 + 83 + CREATE TABLE IF NOT EXISTS oauth_sessions ( 84 + session_key TEXT PRIMARY KEY, 85 + account_did TEXT NOT NULL, 86 + session_id TEXT NOT NULL, 87 + session_data TEXT NOT NULL, 88 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 89 + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 90 + UNIQUE(account_did, session_id) 91 + ); 92 + CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did); 93 + CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC); 94 + 95 + CREATE TABLE IF NOT EXISTS oauth_auth_requests ( 96 + state TEXT PRIMARY KEY, 97 + request_data TEXT NOT NULL, 98 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 99 + ); 100 + CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at); 101 + 102 + CREATE TABLE IF NOT EXISTS ui_sessions ( 103 + id TEXT PRIMARY KEY, 104 + did TEXT NOT NULL, 105 + handle TEXT NOT NULL, 106 + pds_endpoint TEXT NOT NULL, 107 + oauth_session_id TEXT, 108 + expires_at TIMESTAMP NOT NULL, 109 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 110 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 111 + ); 112 + CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did); 113 + CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at); 114 + 115 + CREATE TABLE IF NOT EXISTS devices ( 116 + id TEXT PRIMARY KEY, 117 + did TEXT NOT NULL, 118 + handle TEXT NOT NULL, 119 + name TEXT NOT NULL, 120 + secret_hash TEXT NOT NULL UNIQUE, 121 + ip_address TEXT, 122 + location TEXT, 123 + user_agent TEXT, 124 + created_at TIMESTAMP NOT NULL, 125 + last_used TIMESTAMP, 126 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 127 + ); 128 + CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did); 129 + CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash); 130 + 131 + CREATE TABLE IF NOT EXISTS pending_device_auth ( 132 + device_code TEXT PRIMARY KEY, 133 + user_code TEXT NOT NULL UNIQUE, 134 + device_name TEXT NOT NULL, 135 + ip_address TEXT, 136 + user_agent TEXT, 137 + expires_at TIMESTAMP NOT NULL, 138 + approved_did TEXT, 139 + approved_at TIMESTAMP, 140 + device_secret TEXT, 141 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 142 + ); 143 + CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code); 144 + CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at); 145 + 146 + CREATE TABLE IF NOT EXISTS repository_stats ( 147 + did TEXT NOT NULL, 148 + repository TEXT NOT NULL, 149 + pull_count INTEGER NOT NULL DEFAULT 0, 150 + last_pull TIMESTAMP, 151 + push_count INTEGER NOT NULL DEFAULT 0, 152 + last_push TIMESTAMP, 153 + PRIMARY KEY(did, repository), 154 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 155 + ); 156 + CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did); 157 + CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC); 158 + 159 + CREATE TABLE IF NOT EXISTS stars ( 160 + starrer_did TEXT NOT NULL, 161 + owner_did TEXT NOT NULL, 162 + repository TEXT NOT NULL, 163 + created_at TIMESTAMP NOT NULL, 164 + PRIMARY KEY(starrer_did, owner_did, repository), 165 + FOREIGN KEY(starrer_did) REFERENCES users(did) ON DELETE CASCADE, 166 + FOREIGN KEY(owner_did) REFERENCES users(did) ON DELETE CASCADE 167 + ); 168 + CREATE INDEX IF NOT EXISTS idx_stars_owner_repo ON stars(owner_did, repository); 169 + CREATE INDEX IF NOT EXISTS idx_stars_starrer ON stars(starrer_did); 170 + 171 + CREATE TABLE IF NOT EXISTS hold_captain_records ( 172 + hold_did TEXT PRIMARY KEY, 173 + owner_did TEXT NOT NULL, 174 + public BOOLEAN NOT NULL, 175 + allow_all_crew BOOLEAN NOT NULL, 176 + deployed_at TEXT, 177 + region TEXT, 178 + provider TEXT, 179 + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 180 + ); 181 + CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at); 182 + 183 + CREATE TABLE IF NOT EXISTS hold_crew_approvals ( 184 + hold_did TEXT NOT NULL, 185 + user_did TEXT NOT NULL, 186 + approved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 187 + expires_at TIMESTAMP NOT NULL, 188 + PRIMARY KEY(hold_did, user_did) 189 + ); 190 + CREATE INDEX IF NOT EXISTS idx_crew_approvals_expires ON hold_crew_approvals(expires_at); 191 + 192 + CREATE TABLE IF NOT EXISTS hold_crew_denials ( 193 + hold_did TEXT NOT NULL, 194 + user_did TEXT NOT NULL, 195 + denial_count INTEGER NOT NULL DEFAULT 1, 196 + next_retry_at TIMESTAMP NOT NULL, 197 + last_denied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 198 + PRIMARY KEY(hold_did, user_did) 199 + ); 200 + CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at); 201 + 202 + CREATE TABLE IF NOT EXISTS readme_cache ( 203 + url TEXT PRIMARY KEY, 204 + html TEXT NOT NULL, 205 + fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 206 + ); 207 + CREATE INDEX IF NOT EXISTS idx_readme_cache_fetched ON readme_cache(fetched_at);
+22 -1
pkg/appview/handlers/repository.go
··· 12 12 "atcr.io/pkg/appview/db" 13 13 "atcr.io/pkg/appview/holdhealth" 14 14 "atcr.io/pkg/appview/middleware" 15 + "atcr.io/pkg/appview/readme" 15 16 "atcr.io/pkg/atproto" 16 17 "atcr.io/pkg/auth/oauth" 17 18 "github.com/bluesky-social/indigo/atproto/identity" ··· 26 27 Directory identity.Directory 27 28 Refresher *oauth.Refresher 28 29 HealthChecker *holdhealth.Checker 30 + ReadmeCache *readme.Cache 29 31 } 30 32 31 33 func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 135 137 } 136 138 137 139 // Fetch repository metadata from most recent manifest 138 - title, description, sourceURL, documentationURL, licenses, iconURL, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository) 140 + title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL, err := db.GetRepositoryMetadata(h.DB, owner.DID, repository) 139 141 if err != nil { 140 142 log.Printf("Failed to fetch repository metadata: %v", err) 141 143 // Continue without metadata on error ··· 146 148 repo.DocumentationURL = documentationURL 147 149 repo.Licenses = licenses 148 150 repo.IconURL = iconURL 151 + repo.ReadmeURL = readmeURL 149 152 } 150 153 151 154 // Fetch star count ··· 180 183 isOwner = (user.DID == owner.DID) 181 184 } 182 185 186 + // Fetch README content if available 187 + var readmeHTML template.HTML 188 + if repo.ReadmeURL != "" && h.ReadmeCache != nil { 189 + // Fetch with timeout 190 + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 191 + defer cancel() 192 + 193 + html, err := h.ReadmeCache.Get(ctx, repo.ReadmeURL) 194 + if err != nil { 195 + log.Printf("Failed to fetch README from %s: %v", repo.ReadmeURL, err) 196 + // Continue without README on error 197 + } else { 198 + readmeHTML = template.HTML(html) 199 + } 200 + } 201 + 183 202 data := struct { 184 203 PageData 185 204 Owner *db.User // Repository owner ··· 189 208 StarCount int 190 209 IsStarred bool 191 210 IsOwner bool // Whether current user owns this repository 211 + ReadmeHTML template.HTML 192 212 }{ 193 213 PageData: NewPageData(r, h.RegistryURL), 194 214 Owner: owner, ··· 198 218 StarCount: stats.StarCount, 199 219 IsStarred: isStarred, 200 220 IsOwner: isOwner, 221 + ReadmeHTML: readmeHTML, 201 222 } 202 223 203 224 if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
+3 -1
pkg/appview/jetstream/backfill.go
··· 298 298 } 299 299 300 300 // Extract OCI annotations from manifest 301 - var title, description, sourceURL, documentationURL, licenses, iconURL string 301 + var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string 302 302 if manifestRecord.Annotations != nil { 303 303 title = manifestRecord.Annotations["org.opencontainers.image.title"] 304 304 description = manifestRecord.Annotations["org.opencontainers.image.description"] ··· 306 306 documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"] 307 307 licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"] 308 308 iconURL = manifestRecord.Annotations["io.atcr.icon"] 309 + readmeURL = manifestRecord.Annotations["io.atcr.readme"] 309 310 } 310 311 311 312 // Detect manifest type ··· 326 327 DocumentationURL: documentationURL, 327 328 Licenses: licenses, 328 329 IconURL: iconURL, 330 + ReadmeURL: readmeURL, 329 331 } 330 332 331 333 // Set config fields only for image manifests (not manifest lists)
+3 -1
pkg/appview/jetstream/worker.go
··· 442 442 } 443 443 444 444 // Extract OCI annotations from manifest 445 - var title, description, sourceURL, documentationURL, licenses, iconURL string 445 + var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL string 446 446 if manifestRecord.Annotations != nil { 447 447 title = manifestRecord.Annotations["org.opencontainers.image.title"] 448 448 description = manifestRecord.Annotations["org.opencontainers.image.description"] ··· 450 450 documentationURL = manifestRecord.Annotations["org.opencontainers.image.documentation"] 451 451 licenses = manifestRecord.Annotations["org.opencontainers.image.licenses"] 452 452 iconURL = manifestRecord.Annotations["io.atcr.icon"] 453 + readmeURL = manifestRecord.Annotations["io.atcr.readme"] 453 454 } 454 455 455 456 // Detect manifest type ··· 470 471 DocumentationURL: documentationURL, 471 472 Licenses: licenses, 472 473 IconURL: iconURL, 474 + ReadmeURL: readmeURL, 473 475 } 474 476 475 477 // Set config fields only for image manifests (not manifest lists)
+108
pkg/appview/readme/cache.go
··· 1 + package readme 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "time" 8 + ) 9 + 10 + // Cache stores rendered README HTML in the database 11 + type Cache struct { 12 + db *sql.DB 13 + fetcher *Fetcher 14 + ttl time.Duration 15 + } 16 + 17 + // NewCache creates a new README cache 18 + func NewCache(db *sql.DB, ttl time.Duration) *Cache { 19 + if ttl == 0 { 20 + ttl = 1 * time.Hour // Default TTL 21 + } 22 + return &Cache{ 23 + db: db, 24 + fetcher: NewFetcher(), 25 + ttl: ttl, 26 + } 27 + } 28 + 29 + // Get retrieves a README from cache or fetches it 30 + func (c *Cache) Get(ctx context.Context, readmeURL string) (string, error) { 31 + // Try to get from cache 32 + html, fetchedAt, err := c.getFromDB(readmeURL) 33 + if err == nil { 34 + // Check if cache is still valid 35 + if time.Since(fetchedAt) < c.ttl { 36 + return html, nil 37 + } 38 + } 39 + 40 + // Cache miss or expired, fetch fresh content 41 + html, err = c.fetcher.FetchAndRender(ctx, readmeURL) 42 + if err != nil { 43 + // If fetch fails but we have stale cache, return it 44 + if html != "" { 45 + return html, nil 46 + } 47 + return "", err 48 + } 49 + 50 + // Store in cache 51 + if err := c.storeInDB(readmeURL, html); err != nil { 52 + // Log error but don't fail - we have the content 53 + // In production, you'd use proper logging here 54 + fmt.Printf("Failed to cache README: %v\n", err) 55 + } 56 + 57 + return html, nil 58 + } 59 + 60 + // getFromDB retrieves cached README from database 61 + func (c *Cache) getFromDB(readmeURL string) (string, time.Time, error) { 62 + var html string 63 + var fetchedAt time.Time 64 + 65 + err := c.db.QueryRow(` 66 + SELECT html, fetched_at 67 + FROM readme_cache 68 + WHERE url = ? 69 + `, readmeURL).Scan(&html, &fetchedAt) 70 + 71 + if err != nil { 72 + return "", time.Time{}, err 73 + } 74 + 75 + return html, fetchedAt, nil 76 + } 77 + 78 + // storeInDB stores rendered README in database 79 + func (c *Cache) storeInDB(readmeURL, html string) error { 80 + _, err := c.db.Exec(` 81 + INSERT INTO readme_cache (url, html, fetched_at) 82 + VALUES (?, ?, ?) 83 + ON CONFLICT(url) DO UPDATE SET 84 + html = excluded.html, 85 + fetched_at = excluded.fetched_at 86 + `, readmeURL, html, time.Now()) 87 + 88 + return err 89 + } 90 + 91 + // Invalidate removes a README from the cache 92 + func (c *Cache) Invalidate(readmeURL string) error { 93 + _, err := c.db.Exec(` 94 + DELETE FROM readme_cache 95 + WHERE url = ? 96 + `, readmeURL) 97 + return err 98 + } 99 + 100 + // Cleanup removes expired entries from the cache 101 + func (c *Cache) Cleanup() error { 102 + cutoff := time.Now().Add(-c.ttl * 2) // Keep for 2x TTL 103 + _, err := c.db.Exec(` 104 + DELETE FROM readme_cache 105 + WHERE fetched_at < ? 106 + `, cutoff) 107 + return err 108 + }
+210
pkg/appview/readme/fetcher.go
··· 1 + package readme 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + "time" 12 + 13 + "github.com/microcosm-cc/bluemonday" 14 + "github.com/yuin/goldmark" 15 + "github.com/yuin/goldmark/extension" 16 + "github.com/yuin/goldmark/parser" 17 + "github.com/yuin/goldmark/renderer/html" 18 + ) 19 + 20 + // Fetcher fetches and renders README content from URLs 21 + type Fetcher struct { 22 + httpClient *http.Client 23 + markdown goldmark.Markdown 24 + sanitizer *bluemonday.Policy 25 + } 26 + 27 + // NewFetcher creates a new README fetcher 28 + func NewFetcher() *Fetcher { 29 + // Configure markdown renderer with GitHub-flavored markdown 30 + md := goldmark.New( 31 + goldmark.WithExtensions( 32 + extension.GFM, // GitHub Flavored Markdown 33 + extension.Typographer, // Smart quotes, dashes, etc. 34 + ), 35 + goldmark.WithParserOptions( 36 + parser.WithAutoHeadingID(), // Auto-generate heading IDs 37 + ), 38 + goldmark.WithRendererOptions( 39 + html.WithHardWraps(), // Line breaks create <br> 40 + html.WithXHTML(), // XHTML-compliant output 41 + // html.WithUnsafe(), // Uncomment ONLY if you want to allow raw HTML in markdown (not recommended) 42 + ), 43 + ) 44 + 45 + // Configure HTML sanitizer as a safety net 46 + // This catches any HTML that makes it through (if WithUnsafe() is enabled) 47 + sanitizer := bluemonday.UGCPolicy() 48 + // Allow additional attributes for better markdown rendering 49 + sanitizer.AllowAttrs("class").Globally() 50 + sanitizer.AllowAttrs("id").Globally() 51 + sanitizer.AllowAttrs("align").OnElements("img", "div", "p", "span") 52 + 53 + return &Fetcher{ 54 + httpClient: &http.Client{ 55 + Timeout: 10 * time.Second, 56 + CheckRedirect: func(req *http.Request, via []*http.Request) error { 57 + // Allow up to 5 redirects 58 + if len(via) >= 5 { 59 + return fmt.Errorf("too many redirects") 60 + } 61 + return nil 62 + }, 63 + }, 64 + markdown: md, 65 + sanitizer: sanitizer, 66 + } 67 + } 68 + 69 + // FetchAndRender fetches a README from a URL and renders it as HTML 70 + // Returns the rendered HTML and any error 71 + func (f *Fetcher) FetchAndRender(ctx context.Context, readmeURL string) (string, error) { 72 + // Validate URL 73 + if readmeURL == "" { 74 + return "", fmt.Errorf("empty README URL") 75 + } 76 + 77 + parsedURL, err := url.Parse(readmeURL) 78 + if err != nil { 79 + return "", fmt.Errorf("invalid README URL: %w", err) 80 + } 81 + 82 + // Only allow HTTP/HTTPS 83 + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 84 + return "", fmt.Errorf("invalid URL scheme: %s", parsedURL.Scheme) 85 + } 86 + 87 + // Fetch content 88 + content, baseURL, err := f.fetchContent(ctx, readmeURL) 89 + if err != nil { 90 + return "", err 91 + } 92 + 93 + // Render markdown to HTML 94 + html, err := f.renderMarkdown(content, baseURL) 95 + if err != nil { 96 + return "", fmt.Errorf("failed to render markdown: %w", err) 97 + } 98 + 99 + return html, nil 100 + } 101 + 102 + // fetchContent fetches the raw content from a URL 103 + func (f *Fetcher) fetchContent(ctx context.Context, urlStr string) ([]byte, string, error) { 104 + req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) 105 + if err != nil { 106 + return nil, "", fmt.Errorf("failed to create request: %w", err) 107 + } 108 + 109 + // Set user agent 110 + req.Header.Set("User-Agent", "ATCR-README-Fetcher/1.0") 111 + 112 + resp, err := f.httpClient.Do(req) 113 + if err != nil { 114 + return nil, "", fmt.Errorf("failed to fetch URL: %w", err) 115 + } 116 + defer resp.Body.Close() 117 + 118 + if resp.StatusCode != http.StatusOK { 119 + return nil, "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) 120 + } 121 + 122 + // Limit content size to 1MB 123 + limitedReader := io.LimitReader(resp.Body, 1*1024*1024) 124 + content, err := io.ReadAll(limitedReader) 125 + if err != nil { 126 + return nil, "", fmt.Errorf("failed to read response body: %w", err) 127 + } 128 + 129 + // Get base URL for relative link resolution 130 + baseURL := getBaseURL(resp.Request.URL) 131 + 132 + return content, baseURL, nil 133 + } 134 + 135 + // renderMarkdown renders markdown content to sanitized HTML 136 + func (f *Fetcher) renderMarkdown(content []byte, baseURL string) (string, error) { 137 + var buf bytes.Buffer 138 + 139 + if err := f.markdown.Convert(content, &buf); err != nil { 140 + return "", err 141 + } 142 + 143 + // Rewrite relative URLs to absolute 144 + html := buf.String() 145 + if baseURL != "" { 146 + html = rewriteRelativeURLs(html, baseURL) 147 + } 148 + 149 + // Sanitize HTML 150 + sanitized := f.sanitizer.Sanitize(html) 151 + 152 + return sanitized, nil 153 + } 154 + 155 + // getBaseURL extracts the base URL for relative link resolution 156 + func getBaseURL(u *url.URL) string { 157 + if u == nil { 158 + return "" 159 + } 160 + 161 + // For GitHub raw URLs, convert to blob URL base for relative links 162 + // e.g., https://raw.githubusercontent.com/user/repo/main/README.md 163 + // -> https://github.com/user/repo/blob/main/ 164 + if u.Host == "raw.githubusercontent.com" { 165 + parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") 166 + if len(parts) >= 3 { 167 + user := parts[0] 168 + repo := parts[1] 169 + branch := parts[2] 170 + return fmt.Sprintf("https://github.com/%s/%s/blob/%s/", user, repo, branch) 171 + } 172 + } 173 + 174 + // For other URLs, use the directory containing the file 175 + path := u.Path 176 + lastSlash := strings.LastIndex(path, "/") 177 + if lastSlash >= 0 { 178 + path = path[:lastSlash+1] 179 + } 180 + return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, path) 181 + } 182 + 183 + // rewriteRelativeURLs converts relative URLs to absolute URLs 184 + func rewriteRelativeURLs(html, baseURL string) string { 185 + if baseURL == "" { 186 + return html 187 + } 188 + 189 + base, err := url.Parse(baseURL) 190 + if err != nil { 191 + return html 192 + } 193 + 194 + // Simple string replacement for common patterns 195 + // This is a basic implementation - for production, consider using an HTML parser 196 + html = strings.ReplaceAll(html, `src="./`, fmt.Sprintf(`src="%s`, baseURL)) 197 + html = strings.ReplaceAll(html, `href="./`, fmt.Sprintf(`href="%s`, baseURL)) 198 + html = strings.ReplaceAll(html, `src="../`, fmt.Sprintf(`src="%s../`, baseURL)) 199 + html = strings.ReplaceAll(html, `href="../`, fmt.Sprintf(`href="%s../`, baseURL)) 200 + 201 + // Handle root-relative URLs (starting with /) 202 + if base.Scheme != "" && base.Host != "" { 203 + root := fmt.Sprintf("%s://%s/", base.Scheme, base.Host) 204 + // Replace src="/" and href="/" but not src="//" (absolute URLs) 205 + html = strings.ReplaceAll(html, `src="/`, fmt.Sprintf(`src="%s`, root)) 206 + html = strings.ReplaceAll(html, `href="/`, fmt.Sprintf(`href="%s`, root)) 207 + } 208 + 209 + return html 210 + }
+213
pkg/appview/static/css/style.css
··· 1639 1639 grid-template-columns: repeat(3, 1fr); 1640 1640 } 1641 1641 } 1642 + 1643 + /* README and Repository Layout */ 1644 + .repo-content-layout { 1645 + display: grid; 1646 + grid-template-columns: 1fr 400px; 1647 + gap: 2rem; 1648 + margin-top: 2rem; 1649 + } 1650 + 1651 + .readme-section { 1652 + background: var(--bg); 1653 + border: 1px solid var(--border); 1654 + border-radius: 8px; 1655 + padding: 2rem; 1656 + } 1657 + 1658 + .readme-section h2 { 1659 + margin-bottom: 1.5rem; 1660 + padding-bottom: 0.5rem; 1661 + border-bottom: 2px solid var(--border); 1662 + } 1663 + 1664 + .readme-content { 1665 + overflow-wrap: break-word; 1666 + } 1667 + 1668 + .repo-sidebar { 1669 + display: flex; 1670 + flex-direction: column; 1671 + gap: 1.5rem; 1672 + } 1673 + 1674 + /* Markdown Styling */ 1675 + .markdown-body { 1676 + font-size: 1rem; 1677 + line-height: 1.6; 1678 + word-wrap: break-word; 1679 + } 1680 + 1681 + .markdown-body h1, 1682 + .markdown-body h2, 1683 + .markdown-body h3, 1684 + .markdown-body h4, 1685 + .markdown-body h5, 1686 + .markdown-body h6 { 1687 + margin-top: 1.5rem; 1688 + margin-bottom: 1rem; 1689 + font-weight: 600; 1690 + line-height: 1.25; 1691 + } 1692 + 1693 + .markdown-body h1 { 1694 + font-size: 2rem; 1695 + border-bottom: 1px solid var(--border); 1696 + padding-bottom: 0.3rem; 1697 + } 1698 + 1699 + .markdown-body h2 { 1700 + font-size: 1.5rem; 1701 + border-bottom: 1px solid var(--border); 1702 + padding-bottom: 0.3rem; 1703 + } 1704 + 1705 + .markdown-body h3 { 1706 + font-size: 1.25rem; 1707 + } 1708 + 1709 + .markdown-body h4 { 1710 + font-size: 1rem; 1711 + } 1712 + 1713 + .markdown-body h5 { 1714 + font-size: 0.875rem; 1715 + } 1716 + 1717 + .markdown-body h6 { 1718 + font-size: 0.85rem; 1719 + color: var(--secondary); 1720 + } 1721 + 1722 + .markdown-body p { 1723 + margin-bottom: 1rem; 1724 + } 1725 + 1726 + .markdown-body ul, 1727 + .markdown-body ol { 1728 + margin-bottom: 1rem; 1729 + padding-left: 2rem; 1730 + } 1731 + 1732 + .markdown-body li { 1733 + margin-bottom: 0.25rem; 1734 + } 1735 + 1736 + .markdown-body li > p { 1737 + margin-bottom: 0.5rem; 1738 + } 1739 + 1740 + .markdown-body a { 1741 + color: var(--primary); 1742 + text-decoration: none; 1743 + } 1744 + 1745 + .markdown-body a:hover { 1746 + text-decoration: underline; 1747 + } 1748 + 1749 + .markdown-body code { 1750 + background: var(--code-bg); 1751 + padding: 0.2rem 0.4rem; 1752 + border-radius: 3px; 1753 + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; 1754 + font-size: 0.9em; 1755 + } 1756 + 1757 + .markdown-body pre { 1758 + background: var(--code-bg); 1759 + padding: 1rem; 1760 + border-radius: 6px; 1761 + overflow-x: auto; 1762 + margin-bottom: 1rem; 1763 + } 1764 + 1765 + .markdown-body pre code { 1766 + background: none; 1767 + padding: 0; 1768 + font-size: 0.875rem; 1769 + } 1770 + 1771 + .markdown-body blockquote { 1772 + padding: 0 1rem; 1773 + margin-bottom: 1rem; 1774 + color: var(--secondary); 1775 + border-left: 4px solid var(--border); 1776 + } 1777 + 1778 + .markdown-body table { 1779 + border-collapse: collapse; 1780 + width: 100%; 1781 + margin-bottom: 1rem; 1782 + } 1783 + 1784 + .markdown-body table th, 1785 + .markdown-body table td { 1786 + padding: 0.5rem 1rem; 1787 + border: 1px solid var(--border); 1788 + text-align: left; 1789 + } 1790 + 1791 + .markdown-body table th { 1792 + background: var(--code-bg); 1793 + font-weight: 600; 1794 + } 1795 + 1796 + .markdown-body table tr:nth-child(even) { 1797 + background: var(--hover-bg); 1798 + } 1799 + 1800 + .markdown-body img { 1801 + max-width: 100%; 1802 + height: auto; 1803 + margin: 1rem 0; 1804 + } 1805 + 1806 + .markdown-body hr { 1807 + height: 0.25rem; 1808 + margin: 1.5rem 0; 1809 + background: var(--border); 1810 + border: 0; 1811 + } 1812 + 1813 + /* Task lists */ 1814 + .markdown-body input[type="checkbox"] { 1815 + margin-right: 0.5rem; 1816 + } 1817 + 1818 + .markdown-body .task-list-item { 1819 + list-style-type: none; 1820 + } 1821 + 1822 + .markdown-body .task-list-item input { 1823 + margin: 0 0.2rem 0.25rem -1.6rem; 1824 + vertical-align: middle; 1825 + } 1826 + 1827 + /* Responsive Layout */ 1828 + @media (max-width: 1024px) { 1829 + .repo-content-layout { 1830 + grid-template-columns: 1fr; 1831 + } 1832 + 1833 + .repo-sidebar { 1834 + order: -1; /* Show sidebar first on mobile */ 1835 + } 1836 + } 1837 + 1838 + @media (max-width: 768px) { 1839 + .readme-section { 1840 + padding: 1rem; 1841 + } 1842 + 1843 + .markdown-body h1 { 1844 + font-size: 1.5rem; 1845 + } 1846 + 1847 + .markdown-body h2 { 1848 + font-size: 1.25rem; 1849 + } 1850 + 1851 + .markdown-body pre { 1852 + padding: 0.75rem; 1853 + } 1854 + }
+20
pkg/appview/templates/pages/repository.html
··· 83 83 </div> 84 84 </div> 85 85 86 + <!-- README and Tags/Manifests Layout --> 87 + {{ if .ReadmeHTML }} 88 + <div class="repo-content-layout"> 89 + <!-- README Section (Left) --> 90 + <div class="readme-section"> 91 + <h2>Overview</h2> 92 + <div class="readme-content markdown-body"> 93 + {{ .ReadmeHTML }} 94 + </div> 95 + </div> 96 + 97 + <!-- Tags and Manifests (Right) --> 98 + <div class="repo-sidebar"> 99 + {{ end }} 100 + 86 101 <!-- Tags Section --> 87 102 <div class="repo-section"> 88 103 <h2>Tags</h2> ··· 213 228 <p class="empty-message">No manifests available</p> 214 229 {{ end }} 215 230 </div> 231 + 232 + {{ if .ReadmeHTML }} 233 + </div><!-- Close repo-sidebar --> 234 + </div><!-- Close repo-content-layout --> 235 + {{ end }} 216 236 </div> 217 237 </main> 218 238