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.

better open graph

+989 -86
+27
.air.toml
··· 1 + root = "." 2 + tmp_dir = "tmp" 3 + 4 + [build] 5 + # Pre-build: generate assets if missing (each string is a shell command) 6 + pre_cmd = ["[ -f pkg/appview/static/js/htmx.min.js ] || go generate ./..."] 7 + cmd = "go build -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview" 8 + entrypoint = ["./tmp/atcr-appview", "serve"] 9 + include_ext = ["go", "html", "css", "js"] 10 + exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist"] 11 + exclude_regex = ["_test\\.go$"] 12 + delay = 1000 13 + stop_on_error = true 14 + send_interrupt = true 15 + kill_delay = 500 16 + 17 + [log] 18 + time = false 19 + 20 + [color] 21 + main = "cyan" 22 + watcher = "magenta" 23 + build = "yellow" 24 + runner = "green" 25 + 26 + [misc] 27 + clean_on_exit = true
+1
.gitignore
··· 1 1 # Binaries 2 2 bin/ 3 3 dist/ 4 + tmp/ 4 5 5 6 # Test artifacts 6 7 .atcr-pids
+20 -7
Dockerfile.appview
··· 1 - FROM docker.io/golang:1.25.2-trixie AS builder 1 + # ========================================== 2 + # Stage 1: Development with Air hot reload 3 + # ========================================== 4 + FROM docker.io/golang:1.25.2-trixie AS dev 2 5 3 6 ENV DEBIAN_FRONTEND=noninteractive 4 7 5 8 RUN apt-get update && \ 6 9 apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \ 7 - rm -rf /var/lib/apt/lists/* 10 + rm -rf /var/lib/apt/lists/* && \ 11 + go install github.com/air-verse/air@latest 8 12 9 - WORKDIR /build 13 + WORKDIR /app 10 14 15 + # Copy go.mod first for layer caching 11 16 COPY go.mod go.sum ./ 12 17 RUN go mod download 13 18 19 + # For development: source mounted as volume, Air handles builds 20 + EXPOSE 5000 21 + CMD ["air", "-c", ".air.toml"] 22 + 23 + # ========================================== 24 + # Stage 2: Production build 25 + # ========================================== 26 + FROM dev AS builder 27 + 14 28 COPY . . 15 29 16 30 RUN go generate ./... ··· 21 35 -o atcr-appview ./cmd/appview 22 36 23 37 # ========================================== 24 - # Stage 2: Minimal FROM scratch runtime 38 + # Stage 3: Minimal runtime 25 39 # ========================================== 26 40 FROM scratch 41 + 27 42 # Copy CA certificates for HTTPS (PDS, Jetstream, relay connections) 28 43 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 29 44 # Copy timezone data for timestamp formatting 30 45 COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo 31 46 # Copy optimized binary (SQLite embedded) 32 - COPY --from=builder /build/atcr-appview /atcr-appview 47 + COPY --from=builder /app/atcr-appview /atcr-appview 33 48 34 - # Expose ports 35 49 EXPOSE 5000 36 50 37 - # OCI image annotations 38 51 LABEL org.opencontainers.image.title="ATCR AppView" \ 39 52 org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \ 40 53 org.opencontainers.image.authors="ATCR Contributors" \
+12 -6
Makefile
··· 3 3 4 4 .PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \ 5 5 generate test test-race test-verbose lint clean help install-credential-helper \ 6 - develop develop-detached develop-down 6 + develop develop-detached develop-down dev 7 7 8 8 .DEFAULT_GOAL := help 9 9 ··· 81 81 install -m 755 bin/docker-credential-atcr /usr/local/sbin/docker-credential-atcr 82 82 @echo "✓ Installed docker-credential-atcr to /usr/local/sbin/" 83 83 84 + ##@ Development Targets 85 + 86 + dev: $(GENERATED_ASSETS) ## Run AppView locally with Air hot reload 87 + @which air > /dev/null || (echo "→ Installing Air..." && go install github.com/air-verse/air@latest) 88 + air -c .air.toml 89 + 84 90 ##@ Docker Targets 85 91 86 - develop: ## Build Docker images and start docker-compose for development 92 + develop: ## Build and start docker-compose with Air hot reload 87 93 @echo "→ Building Docker images..." 88 94 docker-compose build 89 - @echo "→ Starting docker-compose..." 95 + @echo "→ Starting docker-compose with hot reload..." 90 96 docker-compose up 91 97 92 - develop-detached: ## Build and start docker-compose in detached mode 98 + develop-detached: ## Build and start docker-compose with hot reload (detached) 93 99 @echo "→ Building Docker images..." 94 100 docker-compose build 95 - @echo "→ Starting docker-compose (detached)..." 101 + @echo "→ Starting docker-compose with hot reload (detached)..." 96 102 docker-compose up -d 97 - @echo "✓ Services started in background" 103 + @echo "✓ Services started in background with hot reload" 98 104 @echo " AppView: http://localhost:5000" 99 105 @echo " Hold: http://localhost:8080" 100 106
+10 -6
docker-compose.yml
··· 3 3 build: 4 4 context: . 5 5 dockerfile: Dockerfile.appview 6 - image: atcr-appview:latest 6 + target: dev 7 + image: atcr-appview-dev:latest 7 8 container_name: atcr-appview 8 9 ports: 9 10 - "5000:5000" ··· 15 16 ATCR_HTTP_ADDR: :5000 16 17 ATCR_DEFAULT_HOLD_DID: did:web:172.28.0.3:8080 17 18 # UI configuration 18 - ATCR_UI_ENABLED: true 19 - ATCR_BACKFILL_ENABLED: true 19 + ATCR_UI_ENABLED: "true" 20 + ATCR_BACKFILL_ENABLED: "true" 20 21 # Test mode - fallback to default hold when user's hold is unreachable 21 - TEST_MODE: true 22 + TEST_MODE: "true" 22 23 # Logging 23 24 ATCR_LOG_LEVEL: debug 24 25 volumes: 25 - # Auth keys (JWT signing keys) 26 - # - atcr-auth:/var/lib/atcr/auth 26 + # Mount source code for Air hot reload 27 + - .:/app 28 + # Cache go modules between rebuilds 29 + - go-mod-cache:/go/pkg/mod 27 30 # UI database (includes OAuth sessions, devices, and Jetstream cache) 28 31 - atcr-ui:/var/lib/atcr 29 32 restart: unless-stopped ··· 82 85 atcr-hold: 83 86 atcr-auth: 84 87 atcr-ui: 88 + go-mod-cache:
+6 -2
go.mod
··· 9 9 github.com/distribution/reference v0.6.0 10 10 github.com/earthboundkid/versioninfo/v2 v2.24.1 11 11 github.com/go-chi/chi/v5 v5.2.3 12 + github.com/goki/freetype v1.0.5 12 13 github.com/golang-jwt/jwt/v5 v5.2.2 13 14 github.com/google/uuid v1.6.0 14 15 github.com/gorilla/websocket v1.5.3 ··· 24 25 github.com/multiformats/go-multihash v0.2.3 25 26 github.com/opencontainers/go-digest v1.0.0 26 27 github.com/spf13/cobra v1.8.0 28 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 29 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 27 30 github.com/stretchr/testify v1.10.0 28 31 github.com/whyrusleeping/cbor-gen v0.3.1 29 32 github.com/yuin/goldmark v1.7.13 30 33 go.opentelemetry.io/otel v1.32.0 31 34 go.yaml.in/yaml/v4 v4.0.0-rc.2 32 35 golang.org/x/crypto v0.39.0 36 + golang.org/x/image v0.34.0 33 37 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 34 38 gorm.io/gorm v1.25.9 35 39 ) ··· 140 144 go.uber.org/multierr v1.11.0 // indirect 141 145 go.uber.org/zap v1.26.0 // indirect 142 146 golang.org/x/net v0.37.0 // indirect 143 - golang.org/x/sync v0.15.0 // indirect 147 + golang.org/x/sync v0.19.0 // indirect 144 148 golang.org/x/sys v0.33.0 // indirect 145 - golang.org/x/text v0.26.0 // indirect 149 + golang.org/x/text v0.32.0 // indirect 146 150 golang.org/x/time v0.6.0 // indirect 147 151 google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect 148 152 google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect
+16 -8
go.sum
··· 90 90 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 91 91 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 92 92 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 93 + github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= 94 + github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= 93 95 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 94 96 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 95 97 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= ··· 367 369 github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 368 370 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 369 371 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 372 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= 373 + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= 374 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= 375 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= 370 376 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 371 377 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 372 378 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= ··· 464 470 golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 465 471 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= 466 472 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 473 + golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= 474 + golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= 467 475 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 468 476 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 469 477 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 470 478 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 471 479 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 472 - golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= 473 - golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 480 + golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= 481 + golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 474 482 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 475 483 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 476 484 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 487 495 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 488 496 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 489 497 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 490 - golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 491 - golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 498 + golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 499 + golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 492 500 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 493 501 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 494 502 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= ··· 507 515 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 508 516 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 509 517 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 510 - golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 511 - golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 518 + golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 519 + golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 512 520 golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 513 521 golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 514 522 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 521 529 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 522 530 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 523 531 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 524 - golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 525 - golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 532 + golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= 533 + golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= 526 534 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 527 535 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 528 536 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+57 -4
pkg/appview/db/schema.go
··· 86 86 continue 87 87 } 88 88 89 - // Apply migration 89 + // Apply migration in a transaction 90 90 slog.Info("Applying migration", "version", m.Version, "name", m.Name, "description", m.Description) 91 - if _, err := db.Exec(m.Query); err != nil { 92 - return fmt.Errorf("failed to apply migration %d (%s): %w", m.Version, m.Name, err) 91 + 92 + tx, err := db.Begin() 93 + if err != nil { 94 + return fmt.Errorf("failed to begin transaction for migration %d: %w", m.Version, err) 95 + } 96 + 97 + // Split query into individual statements and execute each 98 + // go-sqlite3's Exec() doesn't reliably execute all statements in multi-statement queries 99 + statements := splitSQLStatements(m.Query) 100 + for i, stmt := range statements { 101 + if _, err := tx.Exec(stmt); err != nil { 102 + tx.Rollback() 103 + return fmt.Errorf("failed to apply migration %d (%s) statement %d: %w", m.Version, m.Name, i+1, err) 104 + } 93 105 } 94 106 95 107 // Record migration 96 - if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil { 108 + if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil { 109 + tx.Rollback() 97 110 return fmt.Errorf("failed to record migration %d: %w", m.Version, err) 111 + } 112 + 113 + if err := tx.Commit(); err != nil { 114 + return fmt.Errorf("failed to commit migration %d: %w", m.Version, err) 98 115 } 99 116 100 117 slog.Info("Migration applied successfully", "version", m.Version) ··· 144 161 } 145 162 146 163 return migrations, nil 164 + } 165 + 166 + // splitSQLStatements splits a SQL query into individual statements. 167 + // It handles semicolons as statement separators and filters out empty statements. 168 + func splitSQLStatements(query string) []string { 169 + var statements []string 170 + 171 + // Split on semicolons 172 + parts := strings.Split(query, ";") 173 + 174 + for _, part := range parts { 175 + // Trim whitespace 176 + stmt := strings.TrimSpace(part) 177 + 178 + // Skip empty statements (could be trailing semicolon or comment-only) 179 + if stmt == "" { 180 + continue 181 + } 182 + 183 + // Skip comment-only statements 184 + lines := strings.Split(stmt, "\n") 185 + hasCode := false 186 + for _, line := range lines { 187 + trimmed := strings.TrimSpace(line) 188 + if trimmed != "" && !strings.HasPrefix(trimmed, "--") { 189 + hasCode = true 190 + break 191 + } 192 + } 193 + 194 + if hasCode { 195 + statements = append(statements, stmt) 196 + } 197 + } 198 + 199 + return statements 147 200 } 148 201 149 202 // parseMigrationFilename extracts version and name from migration filename
+92
pkg/appview/db/schema_test.go
··· 1 + package db 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestSplitSQLStatements(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + query string 11 + expected []string 12 + }{ 13 + { 14 + name: "single statement", 15 + query: "SELECT 1", 16 + expected: []string{"SELECT 1"}, 17 + }, 18 + { 19 + name: "single statement with semicolon", 20 + query: "SELECT 1;", 21 + expected: []string{"SELECT 1"}, 22 + }, 23 + { 24 + name: "two statements", 25 + query: "SELECT 1; SELECT 2;", 26 + expected: []string{"SELECT 1", "SELECT 2"}, 27 + }, 28 + { 29 + name: "statements with comments", 30 + query: `-- This is a comment 31 + ALTER TABLE foo ADD COLUMN bar TEXT; 32 + 33 + -- Another comment 34 + UPDATE foo SET bar = 'test';`, 35 + expected: []string{ 36 + "-- This is a comment\nALTER TABLE foo ADD COLUMN bar TEXT", 37 + "-- Another comment\nUPDATE foo SET bar = 'test'", 38 + }, 39 + }, 40 + { 41 + name: "comment-only sections filtered", 42 + query: `-- Just a comment 43 + ; 44 + SELECT 1;`, 45 + expected: []string{"SELECT 1"}, 46 + }, 47 + { 48 + name: "empty query", 49 + query: "", 50 + expected: nil, 51 + }, 52 + { 53 + name: "whitespace only", 54 + query: " \n\t ", 55 + expected: nil, 56 + }, 57 + { 58 + name: "migration 0005 format", 59 + query: `-- Add is_attestation column to track attestation manifests 60 + -- Attestation manifests have vnd.docker.reference.type = "attestation-manifest" 61 + ALTER TABLE manifest_references ADD COLUMN is_attestation BOOLEAN DEFAULT FALSE; 62 + 63 + -- Mark existing unknown/unknown platforms as attestations 64 + -- Docker BuildKit attestation manifests always have unknown/unknown platform 65 + UPDATE manifest_references 66 + SET is_attestation = 1 67 + WHERE platform_os = 'unknown' AND platform_architecture = 'unknown';`, 68 + expected: []string{ 69 + "-- Add is_attestation column to track attestation manifests\n-- Attestation manifests have vnd.docker.reference.type = \"attestation-manifest\"\nALTER TABLE manifest_references ADD COLUMN is_attestation BOOLEAN DEFAULT FALSE", 70 + "-- Mark existing unknown/unknown platforms as attestations\n-- Docker BuildKit attestation manifests always have unknown/unknown platform\nUPDATE manifest_references\nSET is_attestation = 1\nWHERE platform_os = 'unknown' AND platform_architecture = 'unknown'", 71 + }, 72 + }, 73 + } 74 + 75 + for _, tt := range tests { 76 + t.Run(tt.name, func(t *testing.T) { 77 + result := splitSQLStatements(tt.query) 78 + 79 + if len(result) != len(tt.expected) { 80 + t.Errorf("got %d statements, want %d\ngot: %v\nwant: %v", 81 + len(result), len(tt.expected), result, tt.expected) 82 + return 83 + } 84 + 85 + for i := range result { 86 + if result[i] != tt.expected[i] { 87 + t.Errorf("statement %d:\ngot: %q\nwant: %q", i, result[i], tt.expected[i]) 88 + } 89 + } 90 + }) 91 + } 92 + }
+3 -16
pkg/appview/handlers/common.go
··· 13 13 User *db.User // Logged-in user (nil if not logged in) 14 14 Query string // Search query from URL parameter 15 15 RegistryURL string // Base registry URL 16 - 17 - // Open Graph meta tag fields - set by individual page handlers 18 - OGTitle string // og:title content 19 - OGDescription string // og:description content 20 - OGImage string // og:image URL 21 - OGType string // og:type (website, profile, etc.) 22 - OGURL string // og:url - canonical URL for the page 23 16 } 24 17 25 18 // NewPageData creates a PageData struct with common fields populated from the request 26 - // Sets default OG values for the home page - individual handlers override these 27 19 func NewPageData(r *http.Request, registryURL string) PageData { 28 20 return PageData{ 29 - User: middleware.GetUser(r), 30 - Query: r.URL.Query().Get("q"), 31 - RegistryURL: registryURL, 32 - OGTitle: "ATCR - Distributed Container Registry", 33 - OGDescription: "Push and pull Docker images on the AT Protocol", 34 - OGImage: registryURL + "/web-app-manifest-512x512.png", 35 - OGType: "website", 36 - OGURL: registryURL, 21 + User: middleware.GetUser(r), 22 + Query: r.URL.Query().Get("q"), 23 + RegistryURL: registryURL, 37 24 } 38 25 } 39 26
+193
pkg/appview/handlers/opengraph.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "strings" 9 + 10 + "atcr.io/pkg/appview/db" 11 + "atcr.io/pkg/appview/ogcard" 12 + "atcr.io/pkg/atproto" 13 + "github.com/go-chi/chi/v5" 14 + ) 15 + 16 + // RepoOGHandler generates OpenGraph images for repository pages 17 + type RepoOGHandler struct { 18 + DB *sql.DB 19 + } 20 + 21 + func (h *RepoOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 22 + handle := chi.URLParam(r, "handle") 23 + repository := chi.URLParam(r, "repository") 24 + 25 + // Resolve handle to DID 26 + did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), handle) 27 + if err != nil { 28 + slog.Warn("Failed to resolve identity for OG image", "handle", handle, "error", err) 29 + http.Error(w, "User not found", http.StatusNotFound) 30 + return 31 + } 32 + 33 + // Get user info 34 + user, err := db.GetUserByDID(h.DB, did) 35 + if err != nil || user == nil { 36 + slog.Warn("Failed to get user for OG image", "did", did, "error", err) 37 + // Use resolved handle even if user not in DB 38 + user = &db.User{DID: did, Handle: resolvedHandle} 39 + } 40 + 41 + // Get repository stats 42 + stats, err := db.GetRepositoryStats(h.DB, did, repository) 43 + if err != nil { 44 + slog.Warn("Failed to get repo stats for OG image", "did", did, "repo", repository, "error", err) 45 + stats = &db.RepositoryStats{} 46 + } 47 + 48 + // Get repository metadata (description, icon) 49 + metadata, err := db.GetRepositoryMetadata(h.DB, did, repository) 50 + if err != nil { 51 + slog.Warn("Failed to get repo metadata for OG image", "did", did, "repo", repository, "error", err) 52 + metadata = map[string]string{} 53 + } 54 + 55 + description := metadata["org.opencontainers.image.description"] 56 + iconURL := metadata["io.atcr.icon"] 57 + version := metadata["org.opencontainers.image.version"] 58 + licenses := metadata["org.opencontainers.image.licenses"] 59 + 60 + // Generate the OG image 61 + card := ogcard.NewCard() 62 + card.Fill(ogcard.ColorBackground) 63 + layout := ogcard.StandardLayout() 64 + 65 + // Draw icon/avatar on the left (prefer repo icon, then user avatar, then placeholder) 66 + avatarURL := iconURL 67 + if avatarURL == "" { 68 + avatarURL = user.Avatar 69 + } 70 + card.DrawAvatarOrPlaceholder(avatarURL, layout.IconX, layout.IconY, ogcard.AvatarSize, 71 + strings.ToUpper(string(repository[0]))) 72 + 73 + // Draw owner handle and repo name on same line: @owner / repo 74 + ownerText := "@" + user.Handle + " / " 75 + card.DrawText(ownerText, layout.TextX, layout.TextY, ogcard.FontTitle, ogcard.ColorMuted, ogcard.AlignLeft, false) 76 + 77 + // Measure owner text width to position repo name 78 + ownerWidth := card.MeasureText(ownerText, ogcard.FontTitle, false) 79 + card.DrawText(repository, layout.TextX+float64(ownerWidth), layout.TextY, ogcard.FontTitle, ogcard.ColorText, ogcard.AlignLeft, true) 80 + 81 + // Draw description (if present, with wrapping) 82 + textY := layout.TextY 83 + if description != "" { 84 + textY += ogcard.LineSpacingSmall 85 + card.DrawTextWrapped(description, layout.TextX, textY, ogcard.FontDescription, ogcard.ColorMuted, layout.MaxWidth, false) 86 + } 87 + 88 + // Badges row (version, license) 89 + badgeY := layout.IconY + ogcard.AvatarSize + 30 90 + badgeX := int(layout.TextX) 91 + 92 + if version != "" { 93 + width := card.DrawBadge(version, badgeX, badgeY, ogcard.FontBadge, ogcard.ColorBadgeAccent, ogcard.ColorText) 94 + badgeX += width + ogcard.BadgeGap 95 + } 96 + 97 + if licenses != "" { 98 + // Show first license if multiple 99 + license := strings.Split(licenses, ",")[0] 100 + license = strings.TrimSpace(license) 101 + card.DrawBadge(license, badgeX, badgeY, ogcard.FontBadge, ogcard.ColorBadgeBg, ogcard.ColorText) 102 + } 103 + 104 + // Stats at bottom 105 + statsX := card.DrawStatWithIcon("star", fmt.Sprintf("%d", stats.StarCount), 106 + ogcard.Padding, layout.StatsY, ogcard.ColorStar, ogcard.ColorText) 107 + card.DrawStatWithIcon("arrow-down-to-line", fmt.Sprintf("%d pulls", stats.PullCount), 108 + statsX, layout.StatsY, ogcard.ColorMuted, ogcard.ColorMuted) 109 + 110 + // ATCR branding (bottom right) 111 + card.DrawBranding() 112 + 113 + // Set cache headers and content type 114 + w.Header().Set("Content-Type", "image/png") 115 + w.Header().Set("Cache-Control", "public, max-age=3600") 116 + 117 + if err := card.EncodePNG(w); err != nil { 118 + slog.Error("Failed to encode OG image", "error", err) 119 + http.Error(w, "Failed to generate image", http.StatusInternalServerError) 120 + } 121 + } 122 + 123 + // UserOGHandler generates OpenGraph images for user profile pages 124 + type UserOGHandler struct { 125 + DB *sql.DB 126 + } 127 + 128 + func (h *UserOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 129 + handle := chi.URLParam(r, "handle") 130 + 131 + // Resolve handle to DID 132 + did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), handle) 133 + if err != nil { 134 + slog.Warn("Failed to resolve identity for OG image", "handle", handle, "error", err) 135 + http.Error(w, "User not found", http.StatusNotFound) 136 + return 137 + } 138 + 139 + // Get user info 140 + user, err := db.GetUserByDID(h.DB, did) 141 + if err != nil || user == nil { 142 + // Use resolved handle even if user not in DB 143 + user = &db.User{DID: did, Handle: resolvedHandle} 144 + } 145 + 146 + // Get repository count 147 + repos, err := db.GetUserRepositories(h.DB, did) 148 + repoCount := 0 149 + if err == nil { 150 + repoCount = len(repos) 151 + } 152 + 153 + // Generate the OG image 154 + card := ogcard.NewCard() 155 + card.Fill(ogcard.ColorBackground) 156 + layout := ogcard.StandardLayout() 157 + 158 + // Draw avatar on the left 159 + firstChar := "?" 160 + if len(user.Handle) > 0 { 161 + firstChar = strings.ToUpper(string(user.Handle[0])) 162 + } 163 + card.DrawAvatarOrPlaceholder(user.Avatar, layout.IconX, layout.IconY, ogcard.AvatarSize, firstChar) 164 + 165 + // Draw handle 166 + handleText := "@" + user.Handle 167 + card.DrawText(handleText, layout.TextX, layout.TextY, ogcard.FontTitle, ogcard.ColorText, ogcard.AlignLeft, true) 168 + 169 + // Repository count below (using description font size) 170 + textY := layout.TextY + ogcard.LineSpacingLarge 171 + repoText := fmt.Sprintf("%d repositories", repoCount) 172 + if repoCount == 1 { 173 + repoText = "1 repository" 174 + } 175 + 176 + // Draw package icon with description-sized text 177 + if err := card.DrawIcon("package", int(layout.TextX), int(textY)-int(ogcard.FontDescription), int(ogcard.FontDescription), ogcard.ColorMuted); err != nil { 178 + slog.Warn("Failed to draw package icon", "error", err) 179 + } 180 + card.DrawText(repoText, layout.TextX+42, textY, ogcard.FontDescription, ogcard.ColorMuted, ogcard.AlignLeft, false) 181 + 182 + // ATCR branding (bottom right) 183 + card.DrawBranding() 184 + 185 + // Set cache headers and content type 186 + w.Header().Set("Content-Type", "image/png") 187 + w.Header().Set("Cache-Control", "public, max-age=3600") 188 + 189 + if err := card.EncodePNG(w); err != nil { 190 + slog.Error("Failed to encode OG image", "error", err) 191 + http.Error(w, "Failed to generate image", http.StatusInternalServerError) 192 + } 193 + }
+1 -17
pkg/appview/handlers/repository.go
··· 206 206 } 207 207 } 208 208 209 - // Build page data with OG tags for repository 210 - pageData := NewPageData(r, h.RegistryURL) 211 - pageData.OGTitle = owner.Handle + "/" + repository + " - ATCR" 212 - pageData.OGType = "website" 213 - pageData.OGURL = h.RegistryURL + "/r/" + owner.Handle + "/" + repository 214 - if repo.Description != "" { 215 - pageData.OGDescription = repo.Description 216 - } else { 217 - pageData.OGDescription = "Container image on ATCR" 218 - } 219 - if repo.IconURL != "" { 220 - pageData.OGImage = repo.IconURL 221 - } else if owner.Avatar != "" { 222 - pageData.OGImage = owner.Avatar 223 - } 224 - 225 209 data := struct { 226 210 PageData 227 211 Owner *db.User // Repository owner ··· 233 217 IsOwner bool // Whether current user owns this repository 234 218 ReadmeHTML template.HTML 235 219 }{ 236 - PageData: pageData, 220 + PageData: NewPageData(r, h.RegistryURL), 237 221 Owner: owner, 238 222 Repository: repo, 239 223 Tags: tagsWithPlatforms,
+1 -11
pkg/appview/handlers/user.go
··· 79 79 }) 80 80 } 81 81 82 - // Build page data with OG tags for user profile 83 - pageData := NewPageData(r, h.RegistryURL) 84 - pageData.OGTitle = viewedUser.Handle + " - ATCR" 85 - pageData.OGDescription = "Container images by " + viewedUser.Handle + " on ATCR" 86 - pageData.OGType = "profile" 87 - pageData.OGURL = h.RegistryURL + "/u/" + viewedUser.Handle 88 - if viewedUser.Avatar != "" { 89 - pageData.OGImage = viewedUser.Avatar 90 - } 91 - 92 82 data := struct { 93 83 PageData 94 84 ViewedUser *db.User // User whose page we're viewing 95 85 Repositories []db.RepoCardData 96 86 HasProfile bool 97 87 }{ 98 - PageData: pageData, 88 + PageData: NewPageData(r, h.RegistryURL), 99 89 ViewedUser: viewedUser, 100 90 Repositories: cards, 101 91 HasProfile: hasProfile,
+413
pkg/appview/ogcard/card.go
··· 1 + // Package ogcard provides OpenGraph card image generation for ATCR. 2 + package ogcard 3 + 4 + import ( 5 + "image" 6 + "image/color" 7 + "image/draw" 8 + _ "image/gif" // Register GIF decoder for image.Decode 9 + _ "image/jpeg" // Register JPEG decoder for image.Decode 10 + "image/png" 11 + "io" 12 + "net/http" 13 + "time" 14 + 15 + "github.com/goki/freetype" 16 + "github.com/goki/freetype/truetype" 17 + xdraw "golang.org/x/image/draw" 18 + "golang.org/x/image/font" 19 + _ "golang.org/x/image/webp" // Register WEBP decoder for image.Decode 20 + ) 21 + 22 + // Text alignment constants 23 + const ( 24 + AlignLeft = iota 25 + AlignCenter 26 + AlignRight 27 + ) 28 + 29 + // Layout constants for OG cards 30 + const ( 31 + // Card dimensions 32 + CardWidth = 1200 33 + CardHeight = 630 34 + 35 + // Padding and sizing 36 + Padding = 60 37 + AvatarSize = 180 38 + 39 + // Positioning offsets 40 + IconTopOffset = 50 // Y offset from padding for icon 41 + TextGapAfterIcon = 40 // X gap between icon and text 42 + TextTopOffset = 50 // Y offset from icon top for text baseline 43 + 44 + // Font sizes 45 + FontTitle = 48.0 46 + FontDescription = 32.0 47 + FontStats = 24.0 48 + FontBadge = 20.0 49 + FontBranding = 28.0 50 + 51 + // Spacing 52 + LineSpacingLarge = 65 // Gap after title 53 + LineSpacingSmall = 60 // Gap between description lines 54 + StatsIconGap = 30 // Gap between stat icon and text 55 + StatsItemGap = 40 // Gap between stat items 56 + BadgeGap = 15 // Gap between badges 57 + ) 58 + 59 + // Layout holds computed positions for a standard OG card layout 60 + type Layout struct { 61 + IconX int 62 + IconY int 63 + TextX float64 64 + TextY float64 65 + StatsY int 66 + MaxWidth int // For text wrapping 67 + } 68 + 69 + // StandardLayout returns the standard OG card layout with computed positions 70 + func StandardLayout() Layout { 71 + iconX := Padding 72 + iconY := Padding + IconTopOffset 73 + textX := float64(iconX + AvatarSize + TextGapAfterIcon) 74 + textY := float64(iconY + TextTopOffset) 75 + statsY := CardHeight - Padding - 10 76 + maxWidth := CardWidth - int(textX) - Padding 77 + 78 + return Layout{ 79 + IconX: iconX, 80 + IconY: iconY, 81 + TextX: textX, 82 + TextY: textY, 83 + StatsY: statsY, 84 + MaxWidth: maxWidth, 85 + } 86 + } 87 + 88 + // Card represents an OG image canvas 89 + type Card struct { 90 + img *image.RGBA 91 + width int 92 + height int 93 + } 94 + 95 + // NewCard creates a new OG card with the standard 1200x630 dimensions 96 + func NewCard() *Card { 97 + return NewCardWithSize(1200, 630) 98 + } 99 + 100 + // NewCardWithSize creates a new OG card with custom dimensions 101 + func NewCardWithSize(width, height int) *Card { 102 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 103 + return &Card{ 104 + img: img, 105 + width: width, 106 + height: height, 107 + } 108 + } 109 + 110 + // Fill fills the entire card with a solid color 111 + func (c *Card) Fill(col color.Color) { 112 + draw.Draw(c.img, c.img.Bounds(), &image.Uniform{col}, image.Point{}, draw.Src) 113 + } 114 + 115 + // DrawRect draws a filled rectangle 116 + func (c *Card) DrawRect(x, y, w, h int, col color.Color) { 117 + rect := image.Rect(x, y, x+w, y+h) 118 + draw.Draw(c.img, rect, &image.Uniform{col}, image.Point{}, draw.Over) 119 + } 120 + 121 + // DrawText draws text at the specified position 122 + func (c *Card) DrawText(text string, x, y float64, size float64, col color.Color, align int, bold bool) error { 123 + f := regularFont 124 + if bold { 125 + f = boldFont 126 + } 127 + if f == nil { 128 + return nil // No font loaded 129 + } 130 + 131 + ctx := freetype.NewContext() 132 + ctx.SetDPI(72) 133 + ctx.SetFont(f) 134 + ctx.SetFontSize(size) 135 + ctx.SetClip(c.img.Bounds()) 136 + ctx.SetDst(c.img) 137 + ctx.SetSrc(image.NewUniform(col)) 138 + 139 + // Calculate text width for alignment 140 + if align != AlignLeft { 141 + opts := truetype.Options{Size: size, DPI: 72} 142 + face := truetype.NewFace(f, &opts) 143 + defer face.Close() 144 + 145 + textWidth := font.MeasureString(face, text).Round() 146 + if align == AlignCenter { 147 + x -= float64(textWidth) / 2 148 + } else if align == AlignRight { 149 + x -= float64(textWidth) 150 + } 151 + } 152 + 153 + pt := freetype.Pt(int(x), int(y)) 154 + _, err := ctx.DrawString(text, pt) 155 + return err 156 + } 157 + 158 + // MeasureText returns the width of text in pixels 159 + func (c *Card) MeasureText(text string, size float64, bold bool) int { 160 + f := regularFont 161 + if bold { 162 + f = boldFont 163 + } 164 + if f == nil { 165 + return 0 166 + } 167 + 168 + opts := truetype.Options{Size: size, DPI: 72} 169 + face := truetype.NewFace(f, &opts) 170 + defer face.Close() 171 + 172 + return font.MeasureString(face, text).Round() 173 + } 174 + 175 + // DrawTextWrapped draws text with word wrapping within maxWidth 176 + // Returns the Y position after the last line 177 + func (c *Card) DrawTextWrapped(text string, x, y float64, size float64, col color.Color, maxWidth int, bold bool) float64 { 178 + words := splitWords(text) 179 + if len(words) == 0 { 180 + return y 181 + } 182 + 183 + lineHeight := size * 1.3 184 + currentLine := "" 185 + currentY := y 186 + 187 + for _, word := range words { 188 + testLine := currentLine 189 + if testLine != "" { 190 + testLine += " " 191 + } 192 + testLine += word 193 + 194 + lineWidth := c.MeasureText(testLine, size, bold) 195 + if lineWidth > maxWidth && currentLine != "" { 196 + // Draw current line and start new one 197 + c.DrawText(currentLine, x, currentY, size, col, AlignLeft, bold) 198 + currentY += lineHeight 199 + currentLine = word 200 + } else { 201 + currentLine = testLine 202 + } 203 + } 204 + 205 + // Draw remaining text 206 + if currentLine != "" { 207 + c.DrawText(currentLine, x, currentY, size, col, AlignLeft, bold) 208 + currentY += lineHeight 209 + } 210 + 211 + return currentY 212 + } 213 + 214 + // splitWords splits text into words 215 + func splitWords(text string) []string { 216 + var words []string 217 + current := "" 218 + for _, r := range text { 219 + if r == ' ' || r == '\t' || r == '\n' { 220 + if current != "" { 221 + words = append(words, current) 222 + current = "" 223 + } 224 + } else { 225 + current += string(r) 226 + } 227 + } 228 + if current != "" { 229 + words = append(words, current) 230 + } 231 + return words 232 + } 233 + 234 + // DrawImage draws an image at the specified position 235 + func (c *Card) DrawImage(img image.Image, x, y int) { 236 + bounds := img.Bounds() 237 + rect := image.Rect(x, y, x+bounds.Dx(), y+bounds.Dy()) 238 + draw.Draw(c.img, rect, img, bounds.Min, draw.Over) 239 + } 240 + 241 + // DrawCircularImage draws an image cropped to a circle 242 + func (c *Card) DrawCircularImage(img image.Image, x, y, diameter int) { 243 + // Scale image to fit diameter 244 + scaled := scaleImage(img, diameter, diameter) 245 + 246 + // Create circular mask 247 + mask := createCircleMask(diameter) 248 + 249 + // Draw with mask 250 + rect := image.Rect(x, y, x+diameter, y+diameter) 251 + draw.DrawMask(c.img, rect, scaled, image.Point{}, mask, image.Point{}, draw.Over) 252 + } 253 + 254 + // FetchAndDrawCircularImage fetches an image from URL and draws it as a circle 255 + func (c *Card) FetchAndDrawCircularImage(url string, x, y, diameter int) error { 256 + client := &http.Client{Timeout: 5 * time.Second} 257 + resp, err := client.Get(url) 258 + if err != nil { 259 + return err 260 + } 261 + defer resp.Body.Close() 262 + 263 + img, _, err := image.Decode(resp.Body) 264 + if err != nil { 265 + return err 266 + } 267 + 268 + c.DrawCircularImage(img, x, y, diameter) 269 + return nil 270 + } 271 + 272 + // DrawPlaceholderCircle draws a colored circle with a letter 273 + func (c *Card) DrawPlaceholderCircle(x, y, diameter int, bgColor, textColor color.Color, letter string) { 274 + // Draw filled circle 275 + radius := diameter / 2 276 + centerX := x + radius 277 + centerY := y + radius 278 + 279 + for dy := -radius; dy <= radius; dy++ { 280 + for dx := -radius; dx <= radius; dx++ { 281 + if dx*dx+dy*dy <= radius*radius { 282 + c.img.Set(centerX+dx, centerY+dy, bgColor) 283 + } 284 + } 285 + } 286 + 287 + // Draw letter in center 288 + fontSize := float64(diameter) * 0.5 289 + c.DrawText(letter, float64(centerX), float64(centerY)+fontSize/3, fontSize, textColor, AlignCenter, true) 290 + } 291 + 292 + // DrawRoundedRect draws a filled rounded rectangle 293 + func (c *Card) DrawRoundedRect(x, y, w, h, radius int, col color.Color) { 294 + // Draw main rectangle (without corners) 295 + for dy := radius; dy < h-radius; dy++ { 296 + for dx := 0; dx < w; dx++ { 297 + c.img.Set(x+dx, y+dy, col) 298 + } 299 + } 300 + // Draw top and bottom strips (without corners) 301 + for dy := 0; dy < radius; dy++ { 302 + for dx := radius; dx < w-radius; dx++ { 303 + c.img.Set(x+dx, y+dy, col) 304 + c.img.Set(x+dx, y+h-1-dy, col) 305 + } 306 + } 307 + // Draw rounded corners 308 + for dy := 0; dy < radius; dy++ { 309 + for dx := 0; dx < radius; dx++ { 310 + // Check if point is within circle 311 + cx := radius - dx - 1 312 + cy := radius - dy - 1 313 + if cx*cx+cy*cy <= radius*radius { 314 + // Top-left 315 + c.img.Set(x+dx, y+dy, col) 316 + // Top-right 317 + c.img.Set(x+w-1-dx, y+dy, col) 318 + // Bottom-left 319 + c.img.Set(x+dx, y+h-1-dy, col) 320 + // Bottom-right 321 + c.img.Set(x+w-1-dx, y+h-1-dy, col) 322 + } 323 + } 324 + } 325 + } 326 + 327 + // DrawBadge draws a pill-shaped badge with text 328 + func (c *Card) DrawBadge(text string, x, y int, fontSize float64, bgColor, textColor color.Color) int { 329 + // Measure text width 330 + textWidth := c.MeasureText(text, fontSize, false) 331 + paddingX := 12 332 + paddingY := 6 333 + height := int(fontSize) + paddingY*2 334 + width := textWidth + paddingX*2 335 + radius := height / 2 336 + 337 + // Draw rounded background 338 + c.DrawRoundedRect(x, y, width, height, radius, bgColor) 339 + 340 + // Draw text centered in badge 341 + textX := float64(x + paddingX) 342 + textY := float64(y + paddingY + int(fontSize) - 2) 343 + c.DrawText(text, textX, textY, fontSize, textColor, AlignLeft, false) 344 + 345 + return width 346 + } 347 + 348 + // EncodePNG encodes the card as PNG to the writer 349 + func (c *Card) EncodePNG(w io.Writer) error { 350 + return png.Encode(w, c.img) 351 + } 352 + 353 + // DrawAvatarOrPlaceholder draws a circular avatar from URL, falling back to placeholder 354 + func (c *Card) DrawAvatarOrPlaceholder(url string, x, y, size int, letter string) { 355 + if url != "" { 356 + if err := c.FetchAndDrawCircularImage(url, x, y, size); err == nil { 357 + return 358 + } 359 + } 360 + c.DrawPlaceholderCircle(x, y, size, ColorAccent, ColorText, letter) 361 + } 362 + 363 + // DrawStatWithIcon draws an icon + text stat and returns the next X position 364 + func (c *Card) DrawStatWithIcon(icon string, text string, x, y int, iconColor, textColor color.Color) int { 365 + c.DrawIcon(icon, x, y-int(FontStats), int(FontStats), iconColor) 366 + x += StatsIconGap 367 + c.DrawText(text, float64(x), float64(y), FontStats, textColor, AlignLeft, false) 368 + return x + c.MeasureText(text, FontStats, false) + StatsItemGap 369 + } 370 + 371 + // DrawBranding draws "ATCR" in the bottom-right corner 372 + func (c *Card) DrawBranding() { 373 + y := CardHeight - Padding - 10 374 + c.DrawText("ATCR", float64(CardWidth-Padding), float64(y), FontBranding, ColorMuted, AlignRight, true) 375 + } 376 + 377 + // scaleImage scales an image to the target dimensions 378 + func scaleImage(src image.Image, width, height int) image.Image { 379 + dst := image.NewRGBA(image.Rect(0, 0, width, height)) 380 + xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil) 381 + return dst 382 + } 383 + 384 + // createCircleMask creates a circular alpha mask 385 + func createCircleMask(diameter int) *image.Alpha { 386 + mask := image.NewAlpha(image.Rect(0, 0, diameter, diameter)) 387 + radius := diameter / 2 388 + centerX := radius 389 + centerY := radius 390 + 391 + for y := 0; y < diameter; y++ { 392 + for x := 0; x < diameter; x++ { 393 + dx := x - centerX 394 + dy := y - centerY 395 + if dx*dx+dy*dy <= radius*radius { 396 + mask.SetAlpha(x, y, color.Alpha{A: 255}) 397 + } 398 + } 399 + } 400 + 401 + return mask 402 + } 403 + 404 + // Common colors 405 + var ( 406 + ColorBackground = color.RGBA{R: 13, G: 17, B: 23, A: 255} // #0d1117 - GitHub dark 407 + ColorText = color.RGBA{R: 230, G: 237, B: 243, A: 255} // #e6edf3 - Light text 408 + ColorMuted = color.RGBA{R: 125, G: 133, B: 144, A: 255} // #7d8590 - Muted text 409 + ColorAccent = color.RGBA{R: 47, G: 129, B: 247, A: 255} // #2f81f7 - Blue accent 410 + ColorStar = color.RGBA{R: 227, G: 179, B: 65, A: 255} // #e3b341 - Star yellow 411 + ColorBadgeBg = color.RGBA{R: 33, G: 38, B: 45, A: 255} // #21262d - Badge background 412 + ColorBadgeAccent = color.RGBA{R: 31, G: 111, B: 235, A: 255} // #1f6feb - Blue badge bg 413 + )
+45
pkg/appview/ogcard/font.go
··· 1 + package ogcard 2 + 3 + // Font configuration for OG card rendering. 4 + // Currently uses Go fonts (embedded in golang.org/x/image). 5 + // 6 + // To use custom fonts instead, replace the init() below with: 7 + // 8 + // //go:embed MyFont-Regular.ttf 9 + // var regularFontData []byte 10 + // //go:embed MyFont-Bold.ttf 11 + // var boldFontData []byte 12 + // 13 + // func init() { 14 + // regularFont, _ = truetype.Parse(regularFontData) 15 + // boldFont, _ = truetype.Parse(boldFontData) 16 + // } 17 + 18 + import ( 19 + "log" 20 + 21 + "github.com/goki/freetype/truetype" 22 + "golang.org/x/image/font/gofont/gobold" 23 + "golang.org/x/image/font/gofont/goregular" 24 + ) 25 + 26 + var ( 27 + regularFont *truetype.Font 28 + boldFont *truetype.Font 29 + ) 30 + 31 + func init() { 32 + var err error 33 + 34 + regularFont, err = truetype.Parse(goregular.TTF) 35 + if err != nil { 36 + log.Printf("ogcard: failed to parse Go Regular font: %v", err) 37 + return 38 + } 39 + 40 + boldFont, err = truetype.Parse(gobold.TTF) 41 + if err != nil { 42 + log.Printf("ogcard: failed to parse Go Bold font: %v", err) 43 + return 44 + } 45 + }
+68
pkg/appview/ogcard/icons.go
··· 1 + package ogcard 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "image" 7 + "image/color" 8 + "image/draw" 9 + "strings" 10 + 11 + "github.com/srwiley/oksvg" 12 + "github.com/srwiley/rasterx" 13 + ) 14 + 15 + // Lucide icons as SVG paths (simplified from Lucide icon set) 16 + // These are the path data for 24x24 viewBox icons 17 + var iconPaths = map[string]string{ 18 + // Star icon - outline 19 + "star": `<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`, 20 + 21 + // Star filled 22 + "star-filled": `<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="currentColor"/>`, 23 + 24 + // Arrow down to line (download/pull icon) 25 + "arrow-down-to-line": `<path d="M12 17V3M12 17l-5-5M12 17l5-5M19 21H5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`, 26 + 27 + // Package icon 28 + "package": `<path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`, 29 + } 30 + 31 + // DrawIcon draws a Lucide icon at the specified position with the given size and color 32 + func (c *Card) DrawIcon(name string, x, y, size int, col color.Color) error { 33 + path, ok := iconPaths[name] 34 + if !ok { 35 + return fmt.Errorf("unknown icon: %s", name) 36 + } 37 + 38 + // Build full SVG with color 39 + r, g, b, _ := col.RGBA() 40 + colorStr := fmt.Sprintf("rgb(%d,%d,%d)", r>>8, g>>8, b>>8) 41 + path = strings.ReplaceAll(path, "currentColor", colorStr) 42 + 43 + svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">%s</svg>`, path) 44 + 45 + // Parse SVG 46 + icon, err := oksvg.ReadIconStream(bytes.NewReader([]byte(svg))) 47 + if err != nil { 48 + return fmt.Errorf("failed to parse icon SVG: %w", err) 49 + } 50 + 51 + // Create target image for the icon 52 + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 53 + 54 + // Set up scanner for rasterization 55 + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 56 + raster := rasterx.NewDasher(size, size, scanner) 57 + 58 + // Scale icon to target size 59 + scale := float64(size) / 24.0 60 + icon.SetTarget(0, 0, float64(size), float64(size)) 61 + icon.Draw(raster, scale) 62 + 63 + // Draw icon onto card 64 + rect := image.Rect(x, y, x+size, y+size) 65 + draw.Draw(c.img, rect, iconImg, image.Point{}, draw.Over) 66 + 67 + return nil 68 + }
+9
pkg/appview/routes/routes.go
··· 141 141 }, 142 142 ).ServeHTTP) 143 143 144 + // OpenGraph image generation (public, cacheable) 145 + router.Get("/og/u/{handle}", (&uihandlers.UserOGHandler{ 146 + DB: deps.ReadOnlyDB, 147 + }).ServeHTTP) 148 + 149 + router.Get("/og/r/{handle}/{repository}", (&uihandlers.RepoOGHandler{ 150 + DB: deps.ReadOnlyDB, 151 + }).ServeHTTP) 152 + 144 153 router.Get("/r/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)( 145 154 &uihandlers.RepositoryPageHandler{ 146 155 DB: deps.ReadOnlyDB,
-8
pkg/appview/templates/components/head.html
··· 2 2 <meta charset="UTF-8"> 3 3 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 4 4 5 - <!-- Open Graph Meta Tags --> 6 - <meta property="og:title" content="{{ .OGTitle }}"> 7 - <meta property="og:description" content="{{ .OGDescription }}"> 8 - <meta property="og:image" content="{{ .OGImage }}"> 9 - <meta property="og:type" content="{{ .OGType }}"> 10 - <meta property="og:url" content="{{ .OGURL }}"> 11 - <meta property="og:site_name" content="ATCR"> 12 - 13 5 <!-- Favicons --> 14 6 <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> 15 7 <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+7
pkg/appview/templates/pages/repository.html
··· 3 3 <html lang="en"> 4 4 <head> 5 5 <title>{{ if .Repository.Title }}{{ .Repository.Title }}{{ else }}{{ .Owner.Handle }}/{{ .Repository.Name }}{{ end }} - ATCR</title> 6 + <!-- Open Graph --> 7 + <meta property="og:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR"> 8 + <meta property="og:description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image on ATCR{{ end }}"> 9 + <meta property="og:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"> 10 + <meta property="og:type" content="website"> 11 + <meta property="og:url" content="{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"> 12 + <meta property="og:site_name" content="ATCR"> 6 13 {{ template "head" . }} 7 14 </head> 8 15 <body>
+7
pkg/appview/templates/pages/user.html
··· 3 3 <html lang="en"> 4 4 <head> 5 5 <title>{{ .ViewedUser.Handle }} - ATCR</title> 6 + <!-- Open Graph --> 7 + <meta property="og:title" content="{{ .ViewedUser.Handle }} - ATCR"> 8 + <meta property="og:description" content="Container images by {{ .ViewedUser.Handle }} on ATCR"> 9 + <meta property="og:image" content="https://{{ .RegistryURL }}/og/u/{{ .ViewedUser.Handle }}"> 10 + <meta property="og:type" content="profile"> 11 + <meta property="og:url" content="{{ .RegistryURL }}/u/{{ .ViewedUser.Handle }}"> 12 + <meta property="og:site_name" content="ATCR"> 6 13 {{ template "head" . }} 7 14 </head> 8 15 <body>
+1 -1
pkg/hold/config.go
··· 111 111 cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true" 112 112 cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" 113 113 cfg.Server.DisablePresignedURLs = os.Getenv("DISABLE_PRESIGNED_URLS") == "true" 114 - cfg.Server.RelayEndpoint = getEnvOrDefault("HOLD_RELAY_ENDPOINT", "https://bsky.network") 114 + cfg.Server.RelayEndpoint = os.Getenv("HOLD_RELAY_ENDPOINT") 115 115 cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads 116 116 cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads 117 117