A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
76
fork

Configure Feed

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

better open graph

evan.jarrett.net 5f19213e afbc0397

verified
+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