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

Configure Feed

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

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