diff --git a/.air.toml b/.air.toml
new file mode 100644
index 0000000..6236bf1
--- /dev/null
+++ b/.air.toml
@@ -0,0 +1,27 @@
+root = "."
+tmp_dir = "tmp"
+
+[build]
+# Pre-build: generate assets if missing (each string is a shell command)
+pre_cmd = ["[ -f pkg/appview/static/js/htmx.min.js ] || go generate ./..."]
+cmd = "go build -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview"
+entrypoint = ["./tmp/atcr-appview", "serve"]
+include_ext = ["go", "html", "css", "js"]
+exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist"]
+exclude_regex = ["_test\\.go$"]
+delay = 1000
+stop_on_error = true
+send_interrupt = true
+kill_delay = 500
+
+[log]
+time = false
+
+[color]
+main = "cyan"
+watcher = "magenta"
+build = "yellow"
+runner = "green"
+
+[misc]
+clean_on_exit = true
diff --git a/.gitignore b/.gitignore
index 4efea5c..1c0e539 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Binaries
bin/
dist/
+tmp/
# Test artifacts
.atcr-pids
diff --git a/Dockerfile.appview b/Dockerfile.appview
index a5c00a5..85fd32e 100644
--- a/Dockerfile.appview
+++ b/Dockerfile.appview
@@ -1,16 +1,30 @@
-FROM docker.io/golang:1.25.2-trixie AS builder
+# ==========================================
+# Stage 1: Development with Air hot reload
+# ==========================================
+FROM docker.io/golang:1.25.2-trixie AS dev
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \
- rm -rf /var/lib/apt/lists/*
+ rm -rf /var/lib/apt/lists/* && \
+ go install github.com/air-verse/air@latest
-WORKDIR /build
+WORKDIR /app
+# Copy go.mod first for layer caching
COPY go.mod go.sum ./
RUN go mod download
+# For development: source mounted as volume, Air handles builds
+EXPOSE 5000
+CMD ["air", "-c", ".air.toml"]
+
+# ==========================================
+# Stage 2: Production build
+# ==========================================
+FROM dev AS builder
+
COPY . .
RUN go generate ./...
@@ -21,20 +35,19 @@ RUN CGO_ENABLED=1 go build \
-o atcr-appview ./cmd/appview
# ==========================================
-# Stage 2: Minimal FROM scratch runtime
+# Stage 3: Minimal runtime
# ==========================================
FROM scratch
+
# Copy CA certificates for HTTPS (PDS, Jetstream, relay connections)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy timezone data for timestamp formatting
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Copy optimized binary (SQLite embedded)
-COPY --from=builder /build/atcr-appview /atcr-appview
+COPY --from=builder /app/atcr-appview /atcr-appview
-# Expose ports
EXPOSE 5000
-# OCI image annotations
LABEL org.opencontainers.image.title="ATCR AppView" \
org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \
org.opencontainers.image.authors="ATCR Contributors" \
diff --git a/Makefile b/Makefile
index 2767c66..963139f 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@
.PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \
generate test test-race test-verbose lint clean help install-credential-helper \
- develop develop-detached develop-down
+ develop develop-detached develop-down dev
.DEFAULT_GOAL := help
@@ -81,20 +81,26 @@ install-credential-helper: build-credential-helper ## Install credential helper
install -m 755 bin/docker-credential-atcr /usr/local/sbin/docker-credential-atcr
@echo "✓ Installed docker-credential-atcr to /usr/local/sbin/"
+##@ Development Targets
+
+dev: $(GENERATED_ASSETS) ## Run AppView locally with Air hot reload
+ @which air > /dev/null || (echo "→ Installing Air..." && go install github.com/air-verse/air@latest)
+ air -c .air.toml
+
##@ Docker Targets
-develop: ## Build Docker images and start docker-compose for development
+develop: ## Build and start docker-compose with Air hot reload
@echo "→ Building Docker images..."
docker-compose build
- @echo "→ Starting docker-compose..."
+ @echo "→ Starting docker-compose with hot reload..."
docker-compose up
-develop-detached: ## Build and start docker-compose in detached mode
+develop-detached: ## Build and start docker-compose with hot reload (detached)
@echo "→ Building Docker images..."
docker-compose build
- @echo "→ Starting docker-compose (detached)..."
+ @echo "→ Starting docker-compose with hot reload (detached)..."
docker-compose up -d
- @echo "✓ Services started in background"
+ @echo "✓ Services started in background with hot reload"
@echo " AppView: http://localhost:5000"
@echo " Hold: http://localhost:8080"
diff --git a/docker-compose.yml b/docker-compose.yml
index 4b66dc6..ced22ce 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,7 +3,8 @@ services:
build:
context: .
dockerfile: Dockerfile.appview
- image: atcr-appview:latest
+ target: dev
+ image: atcr-appview-dev:latest
container_name: atcr-appview
ports:
- "5000:5000"
@@ -15,15 +16,17 @@ services:
ATCR_HTTP_ADDR: :5000
ATCR_DEFAULT_HOLD_DID: did:web:172.28.0.3:8080
# UI configuration
- ATCR_UI_ENABLED: true
- ATCR_BACKFILL_ENABLED: true
+ ATCR_UI_ENABLED: "true"
+ ATCR_BACKFILL_ENABLED: "true"
# Test mode - fallback to default hold when user's hold is unreachable
- TEST_MODE: true
+ TEST_MODE: "true"
# Logging
ATCR_LOG_LEVEL: debug
volumes:
- # Auth keys (JWT signing keys)
- # - atcr-auth:/var/lib/atcr/auth
+ # Mount source code for Air hot reload
+ - .:/app
+ # Cache go modules between rebuilds
+ - go-mod-cache:/go/pkg/mod
# UI database (includes OAuth sessions, devices, and Jetstream cache)
- atcr-ui:/var/lib/atcr
restart: unless-stopped
@@ -82,3 +85,4 @@ volumes:
atcr-hold:
atcr-auth:
atcr-ui:
+ go-mod-cache:
diff --git a/go.mod b/go.mod
index cba7a4f..a65e07d 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
github.com/distribution/reference v0.6.0
github.com/earthboundkid/versioninfo/v2 v2.24.1
github.com/go-chi/chi/v5 v5.2.3
+ github.com/goki/freetype v1.0.5
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@@ -24,12 +25,15 @@ require (
github.com/multiformats/go-multihash v0.2.3
github.com/opencontainers/go-digest v1.0.0
github.com/spf13/cobra v1.8.0
+ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
+ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/stretchr/testify v1.10.0
github.com/whyrusleeping/cbor-gen v0.3.1
github.com/yuin/goldmark v1.7.13
go.opentelemetry.io/otel v1.32.0
go.yaml.in/yaml/v4 v4.0.0-rc.2
golang.org/x/crypto v0.39.0
+ golang.org/x/image v0.34.0
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
gorm.io/gorm v1.25.9
)
@@ -140,9 +144,9 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/net v0.37.0 // indirect
- golang.org/x/sync v0.15.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.33.0 // indirect
- golang.org/x/text v0.26.0 // indirect
+ golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.6.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect
diff --git a/go.sum b/go.sum
index 28c9248..b5fad16 100644
--- a/go.sum
+++ b/go.sum
@@ -90,6 +90,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A=
+github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -367,6 +369,10 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@@ -464,13 +470,15 @@ golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
+golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
+golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
-golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -487,8 +495,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
-golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -507,8 +515,8 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
-golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -521,8 +529,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
-golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/pkg/appview/db/schema.go b/pkg/appview/db/schema.go
index 1e1ed89..5de2d79 100644
--- a/pkg/appview/db/schema.go
+++ b/pkg/appview/db/schema.go
@@ -86,17 +86,34 @@ func runMigrations(db *sql.DB) error {
continue
}
- // Apply migration
+ // Apply migration in a transaction
slog.Info("Applying migration", "version", m.Version, "name", m.Name, "description", m.Description)
- if _, err := db.Exec(m.Query); err != nil {
- return fmt.Errorf("failed to apply migration %d (%s): %w", m.Version, m.Name, err)
+
+ tx, err := db.Begin()
+ if err != nil {
+ return fmt.Errorf("failed to begin transaction for migration %d: %w", m.Version, err)
+ }
+
+ // Split query into individual statements and execute each
+ // go-sqlite3's Exec() doesn't reliably execute all statements in multi-statement queries
+ statements := splitSQLStatements(m.Query)
+ for i, stmt := range statements {
+ if _, err := tx.Exec(stmt); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("failed to apply migration %d (%s) statement %d: %w", m.Version, m.Name, i+1, err)
+ }
}
// Record migration
- if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
+ if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
+ tx.Rollback()
return fmt.Errorf("failed to record migration %d: %w", m.Version, err)
}
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("failed to commit migration %d: %w", m.Version, err)
+ }
+
slog.Info("Migration applied successfully", "version", m.Version)
}
@@ -146,6 +163,42 @@ func loadMigrations() ([]Migration, error) {
return migrations, nil
}
+// splitSQLStatements splits a SQL query into individual statements.
+// It handles semicolons as statement separators and filters out empty statements.
+func splitSQLStatements(query string) []string {
+ var statements []string
+
+ // Split on semicolons
+ parts := strings.Split(query, ";")
+
+ for _, part := range parts {
+ // Trim whitespace
+ stmt := strings.TrimSpace(part)
+
+ // Skip empty statements (could be trailing semicolon or comment-only)
+ if stmt == "" {
+ continue
+ }
+
+ // Skip comment-only statements
+ lines := strings.Split(stmt, "\n")
+ hasCode := false
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed != "" && !strings.HasPrefix(trimmed, "--") {
+ hasCode = true
+ break
+ }
+ }
+
+ if hasCode {
+ statements = append(statements, stmt)
+ }
+ }
+
+ return statements
+}
+
// parseMigrationFilename extracts version and name from migration filename
// Expected format: 0001_migration_name.yaml
// Returns: version (int), name (string), error
diff --git a/pkg/appview/db/schema_test.go b/pkg/appview/db/schema_test.go
new file mode 100644
index 0000000..a83a1f2
--- /dev/null
+++ b/pkg/appview/db/schema_test.go
@@ -0,0 +1,92 @@
+package db
+
+import (
+ "testing"
+)
+
+func TestSplitSQLStatements(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ expected []string
+ }{
+ {
+ name: "single statement",
+ query: "SELECT 1",
+ expected: []string{"SELECT 1"},
+ },
+ {
+ name: "single statement with semicolon",
+ query: "SELECT 1;",
+ expected: []string{"SELECT 1"},
+ },
+ {
+ name: "two statements",
+ query: "SELECT 1; SELECT 2;",
+ expected: []string{"SELECT 1", "SELECT 2"},
+ },
+ {
+ name: "statements with comments",
+ query: `-- This is a comment
+ALTER TABLE foo ADD COLUMN bar TEXT;
+
+-- Another comment
+UPDATE foo SET bar = 'test';`,
+ expected: []string{
+ "-- This is a comment\nALTER TABLE foo ADD COLUMN bar TEXT",
+ "-- Another comment\nUPDATE foo SET bar = 'test'",
+ },
+ },
+ {
+ name: "comment-only sections filtered",
+ query: `-- Just a comment
+;
+SELECT 1;`,
+ expected: []string{"SELECT 1"},
+ },
+ {
+ name: "empty query",
+ query: "",
+ expected: nil,
+ },
+ {
+ name: "whitespace only",
+ query: " \n\t ",
+ expected: nil,
+ },
+ {
+ name: "migration 0005 format",
+ query: `-- Add is_attestation column to track attestation manifests
+-- Attestation manifests have vnd.docker.reference.type = "attestation-manifest"
+ALTER TABLE manifest_references ADD COLUMN is_attestation BOOLEAN DEFAULT FALSE;
+
+-- Mark existing unknown/unknown platforms as attestations
+-- Docker BuildKit attestation manifests always have unknown/unknown platform
+UPDATE manifest_references
+SET is_attestation = 1
+WHERE platform_os = 'unknown' AND platform_architecture = 'unknown';`,
+ expected: []string{
+ "-- 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",
+ "-- 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'",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := splitSQLStatements(tt.query)
+
+ if len(result) != len(tt.expected) {
+ t.Errorf("got %d statements, want %d\ngot: %v\nwant: %v",
+ len(result), len(tt.expected), result, tt.expected)
+ return
+ }
+
+ for i := range result {
+ if result[i] != tt.expected[i] {
+ t.Errorf("statement %d:\ngot: %q\nwant: %q", i, result[i], tt.expected[i])
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/appview/handlers/common.go b/pkg/appview/handlers/common.go
index fafff1e..2a731a7 100644
--- a/pkg/appview/handlers/common.go
+++ b/pkg/appview/handlers/common.go
@@ -13,27 +13,14 @@ type PageData struct {
User *db.User // Logged-in user (nil if not logged in)
Query string // Search query from URL parameter
RegistryURL string // Base registry URL
-
- // Open Graph meta tag fields - set by individual page handlers
- OGTitle string // og:title content
- OGDescription string // og:description content
- OGImage string // og:image URL
- OGType string // og:type (website, profile, etc.)
- OGURL string // og:url - canonical URL for the page
}
// NewPageData creates a PageData struct with common fields populated from the request
-// Sets default OG values for the home page - individual handlers override these
func NewPageData(r *http.Request, registryURL string) PageData {
return PageData{
- User: middleware.GetUser(r),
- Query: r.URL.Query().Get("q"),
- RegistryURL: registryURL,
- OGTitle: "ATCR - Distributed Container Registry",
- OGDescription: "Push and pull Docker images on the AT Protocol",
- OGImage: registryURL + "/web-app-manifest-512x512.png",
- OGType: "website",
- OGURL: registryURL,
+ User: middleware.GetUser(r),
+ Query: r.URL.Query().Get("q"),
+ RegistryURL: registryURL,
}
}
diff --git a/pkg/appview/handlers/opengraph.go b/pkg/appview/handlers/opengraph.go
new file mode 100644
index 0000000..e576a9d
--- /dev/null
+++ b/pkg/appview/handlers/opengraph.go
@@ -0,0 +1,193 @@
+package handlers
+
+import (
+ "database/sql"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "atcr.io/pkg/appview/db"
+ "atcr.io/pkg/appview/ogcard"
+ "atcr.io/pkg/atproto"
+ "github.com/go-chi/chi/v5"
+)
+
+// RepoOGHandler generates OpenGraph images for repository pages
+type RepoOGHandler struct {
+ DB *sql.DB
+}
+
+func (h *RepoOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ handle := chi.URLParam(r, "handle")
+ repository := chi.URLParam(r, "repository")
+
+ // Resolve handle to DID
+ did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), handle)
+ if err != nil {
+ slog.Warn("Failed to resolve identity for OG image", "handle", handle, "error", err)
+ http.Error(w, "User not found", http.StatusNotFound)
+ return
+ }
+
+ // Get user info
+ user, err := db.GetUserByDID(h.DB, did)
+ if err != nil || user == nil {
+ slog.Warn("Failed to get user for OG image", "did", did, "error", err)
+ // Use resolved handle even if user not in DB
+ user = &db.User{DID: did, Handle: resolvedHandle}
+ }
+
+ // Get repository stats
+ stats, err := db.GetRepositoryStats(h.DB, did, repository)
+ if err != nil {
+ slog.Warn("Failed to get repo stats for OG image", "did", did, "repo", repository, "error", err)
+ stats = &db.RepositoryStats{}
+ }
+
+ // Get repository metadata (description, icon)
+ metadata, err := db.GetRepositoryMetadata(h.DB, did, repository)
+ if err != nil {
+ slog.Warn("Failed to get repo metadata for OG image", "did", did, "repo", repository, "error", err)
+ metadata = map[string]string{}
+ }
+
+ description := metadata["org.opencontainers.image.description"]
+ iconURL := metadata["io.atcr.icon"]
+ version := metadata["org.opencontainers.image.version"]
+ licenses := metadata["org.opencontainers.image.licenses"]
+
+ // Generate the OG image
+ card := ogcard.NewCard()
+ card.Fill(ogcard.ColorBackground)
+ layout := ogcard.StandardLayout()
+
+ // Draw icon/avatar on the left (prefer repo icon, then user avatar, then placeholder)
+ avatarURL := iconURL
+ if avatarURL == "" {
+ avatarURL = user.Avatar
+ }
+ card.DrawAvatarOrPlaceholder(avatarURL, layout.IconX, layout.IconY, ogcard.AvatarSize,
+ strings.ToUpper(string(repository[0])))
+
+ // Draw owner handle and repo name on same line: @owner / repo
+ ownerText := "@" + user.Handle + " / "
+ card.DrawText(ownerText, layout.TextX, layout.TextY, ogcard.FontTitle, ogcard.ColorMuted, ogcard.AlignLeft, false)
+
+ // Measure owner text width to position repo name
+ ownerWidth := card.MeasureText(ownerText, ogcard.FontTitle, false)
+ card.DrawText(repository, layout.TextX+float64(ownerWidth), layout.TextY, ogcard.FontTitle, ogcard.ColorText, ogcard.AlignLeft, true)
+
+ // Draw description (if present, with wrapping)
+ textY := layout.TextY
+ if description != "" {
+ textY += ogcard.LineSpacingSmall
+ card.DrawTextWrapped(description, layout.TextX, textY, ogcard.FontDescription, ogcard.ColorMuted, layout.MaxWidth, false)
+ }
+
+ // Badges row (version, license)
+ badgeY := layout.IconY + ogcard.AvatarSize + 30
+ badgeX := int(layout.TextX)
+
+ if version != "" {
+ width := card.DrawBadge(version, badgeX, badgeY, ogcard.FontBadge, ogcard.ColorBadgeAccent, ogcard.ColorText)
+ badgeX += width + ogcard.BadgeGap
+ }
+
+ if licenses != "" {
+ // Show first license if multiple
+ license := strings.Split(licenses, ",")[0]
+ license = strings.TrimSpace(license)
+ card.DrawBadge(license, badgeX, badgeY, ogcard.FontBadge, ogcard.ColorBadgeBg, ogcard.ColorText)
+ }
+
+ // Stats at bottom
+ statsX := card.DrawStatWithIcon("star", fmt.Sprintf("%d", stats.StarCount),
+ ogcard.Padding, layout.StatsY, ogcard.ColorStar, ogcard.ColorText)
+ card.DrawStatWithIcon("arrow-down-to-line", fmt.Sprintf("%d pulls", stats.PullCount),
+ statsX, layout.StatsY, ogcard.ColorMuted, ogcard.ColorMuted)
+
+ // ATCR branding (bottom right)
+ card.DrawBranding()
+
+ // Set cache headers and content type
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+
+ if err := card.EncodePNG(w); err != nil {
+ slog.Error("Failed to encode OG image", "error", err)
+ http.Error(w, "Failed to generate image", http.StatusInternalServerError)
+ }
+}
+
+// UserOGHandler generates OpenGraph images for user profile pages
+type UserOGHandler struct {
+ DB *sql.DB
+}
+
+func (h *UserOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ handle := chi.URLParam(r, "handle")
+
+ // Resolve handle to DID
+ did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), handle)
+ if err != nil {
+ slog.Warn("Failed to resolve identity for OG image", "handle", handle, "error", err)
+ http.Error(w, "User not found", http.StatusNotFound)
+ return
+ }
+
+ // Get user info
+ user, err := db.GetUserByDID(h.DB, did)
+ if err != nil || user == nil {
+ // Use resolved handle even if user not in DB
+ user = &db.User{DID: did, Handle: resolvedHandle}
+ }
+
+ // Get repository count
+ repos, err := db.GetUserRepositories(h.DB, did)
+ repoCount := 0
+ if err == nil {
+ repoCount = len(repos)
+ }
+
+ // Generate the OG image
+ card := ogcard.NewCard()
+ card.Fill(ogcard.ColorBackground)
+ layout := ogcard.StandardLayout()
+
+ // Draw avatar on the left
+ firstChar := "?"
+ if len(user.Handle) > 0 {
+ firstChar = strings.ToUpper(string(user.Handle[0]))
+ }
+ card.DrawAvatarOrPlaceholder(user.Avatar, layout.IconX, layout.IconY, ogcard.AvatarSize, firstChar)
+
+ // Draw handle
+ handleText := "@" + user.Handle
+ card.DrawText(handleText, layout.TextX, layout.TextY, ogcard.FontTitle, ogcard.ColorText, ogcard.AlignLeft, true)
+
+ // Repository count below (using description font size)
+ textY := layout.TextY + ogcard.LineSpacingLarge
+ repoText := fmt.Sprintf("%d repositories", repoCount)
+ if repoCount == 1 {
+ repoText = "1 repository"
+ }
+
+ // Draw package icon with description-sized text
+ if err := card.DrawIcon("package", int(layout.TextX), int(textY)-int(ogcard.FontDescription), int(ogcard.FontDescription), ogcard.ColorMuted); err != nil {
+ slog.Warn("Failed to draw package icon", "error", err)
+ }
+ card.DrawText(repoText, layout.TextX+42, textY, ogcard.FontDescription, ogcard.ColorMuted, ogcard.AlignLeft, false)
+
+ // ATCR branding (bottom right)
+ card.DrawBranding()
+
+ // Set cache headers and content type
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+
+ if err := card.EncodePNG(w); err != nil {
+ slog.Error("Failed to encode OG image", "error", err)
+ http.Error(w, "Failed to generate image", http.StatusInternalServerError)
+ }
+}
diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go
index 6f9e1a0..2839969 100644
--- a/pkg/appview/handlers/repository.go
+++ b/pkg/appview/handlers/repository.go
@@ -206,22 +206,6 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
}
- // Build page data with OG tags for repository
- pageData := NewPageData(r, h.RegistryURL)
- pageData.OGTitle = owner.Handle + "/" + repository + " - ATCR"
- pageData.OGType = "website"
- pageData.OGURL = h.RegistryURL + "/r/" + owner.Handle + "/" + repository
- if repo.Description != "" {
- pageData.OGDescription = repo.Description
- } else {
- pageData.OGDescription = "Container image on ATCR"
- }
- if repo.IconURL != "" {
- pageData.OGImage = repo.IconURL
- } else if owner.Avatar != "" {
- pageData.OGImage = owner.Avatar
- }
-
data := struct {
PageData
Owner *db.User // Repository owner
@@ -233,7 +217,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
IsOwner bool // Whether current user owns this repository
ReadmeHTML template.HTML
}{
- PageData: pageData,
+ PageData: NewPageData(r, h.RegistryURL),
Owner: owner,
Repository: repo,
Tags: tagsWithPlatforms,
diff --git a/pkg/appview/handlers/user.go b/pkg/appview/handlers/user.go
index d7f6553..3136e42 100644
--- a/pkg/appview/handlers/user.go
+++ b/pkg/appview/handlers/user.go
@@ -79,23 +79,13 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
})
}
- // Build page data with OG tags for user profile
- pageData := NewPageData(r, h.RegistryURL)
- pageData.OGTitle = viewedUser.Handle + " - ATCR"
- pageData.OGDescription = "Container images by " + viewedUser.Handle + " on ATCR"
- pageData.OGType = "profile"
- pageData.OGURL = h.RegistryURL + "/u/" + viewedUser.Handle
- if viewedUser.Avatar != "" {
- pageData.OGImage = viewedUser.Avatar
- }
-
data := struct {
PageData
ViewedUser *db.User // User whose page we're viewing
Repositories []db.RepoCardData
HasProfile bool
}{
- PageData: pageData,
+ PageData: NewPageData(r, h.RegistryURL),
ViewedUser: viewedUser,
Repositories: cards,
HasProfile: hasProfile,
diff --git a/pkg/appview/ogcard/card.go b/pkg/appview/ogcard/card.go
new file mode 100644
index 0000000..aea6b06
--- /dev/null
+++ b/pkg/appview/ogcard/card.go
@@ -0,0 +1,413 @@
+// Package ogcard provides OpenGraph card image generation for ATCR.
+package ogcard
+
+import (
+ "image"
+ "image/color"
+ "image/draw"
+ _ "image/gif" // Register GIF decoder for image.Decode
+ _ "image/jpeg" // Register JPEG decoder for image.Decode
+ "image/png"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/goki/freetype"
+ "github.com/goki/freetype/truetype"
+ xdraw "golang.org/x/image/draw"
+ "golang.org/x/image/font"
+ _ "golang.org/x/image/webp" // Register WEBP decoder for image.Decode
+)
+
+// Text alignment constants
+const (
+ AlignLeft = iota
+ AlignCenter
+ AlignRight
+)
+
+// Layout constants for OG cards
+const (
+ // Card dimensions
+ CardWidth = 1200
+ CardHeight = 630
+
+ // Padding and sizing
+ Padding = 60
+ AvatarSize = 180
+
+ // Positioning offsets
+ IconTopOffset = 50 // Y offset from padding for icon
+ TextGapAfterIcon = 40 // X gap between icon and text
+ TextTopOffset = 50 // Y offset from icon top for text baseline
+
+ // Font sizes
+ FontTitle = 48.0
+ FontDescription = 32.0
+ FontStats = 24.0
+ FontBadge = 20.0
+ FontBranding = 28.0
+
+ // Spacing
+ LineSpacingLarge = 65 // Gap after title
+ LineSpacingSmall = 60 // Gap between description lines
+ StatsIconGap = 30 // Gap between stat icon and text
+ StatsItemGap = 40 // Gap between stat items
+ BadgeGap = 15 // Gap between badges
+)
+
+// Layout holds computed positions for a standard OG card layout
+type Layout struct {
+ IconX int
+ IconY int
+ TextX float64
+ TextY float64
+ StatsY int
+ MaxWidth int // For text wrapping
+}
+
+// StandardLayout returns the standard OG card layout with computed positions
+func StandardLayout() Layout {
+ iconX := Padding
+ iconY := Padding + IconTopOffset
+ textX := float64(iconX + AvatarSize + TextGapAfterIcon)
+ textY := float64(iconY + TextTopOffset)
+ statsY := CardHeight - Padding - 10
+ maxWidth := CardWidth - int(textX) - Padding
+
+ return Layout{
+ IconX: iconX,
+ IconY: iconY,
+ TextX: textX,
+ TextY: textY,
+ StatsY: statsY,
+ MaxWidth: maxWidth,
+ }
+}
+
+// Card represents an OG image canvas
+type Card struct {
+ img *image.RGBA
+ width int
+ height int
+}
+
+// NewCard creates a new OG card with the standard 1200x630 dimensions
+func NewCard() *Card {
+ return NewCardWithSize(1200, 630)
+}
+
+// NewCardWithSize creates a new OG card with custom dimensions
+func NewCardWithSize(width, height int) *Card {
+ img := image.NewRGBA(image.Rect(0, 0, width, height))
+ return &Card{
+ img: img,
+ width: width,
+ height: height,
+ }
+}
+
+// Fill fills the entire card with a solid color
+func (c *Card) Fill(col color.Color) {
+ draw.Draw(c.img, c.img.Bounds(), &image.Uniform{col}, image.Point{}, draw.Src)
+}
+
+// DrawRect draws a filled rectangle
+func (c *Card) DrawRect(x, y, w, h int, col color.Color) {
+ rect := image.Rect(x, y, x+w, y+h)
+ draw.Draw(c.img, rect, &image.Uniform{col}, image.Point{}, draw.Over)
+}
+
+// DrawText draws text at the specified position
+func (c *Card) DrawText(text string, x, y float64, size float64, col color.Color, align int, bold bool) error {
+ f := regularFont
+ if bold {
+ f = boldFont
+ }
+ if f == nil {
+ return nil // No font loaded
+ }
+
+ ctx := freetype.NewContext()
+ ctx.SetDPI(72)
+ ctx.SetFont(f)
+ ctx.SetFontSize(size)
+ ctx.SetClip(c.img.Bounds())
+ ctx.SetDst(c.img)
+ ctx.SetSrc(image.NewUniform(col))
+
+ // Calculate text width for alignment
+ if align != AlignLeft {
+ opts := truetype.Options{Size: size, DPI: 72}
+ face := truetype.NewFace(f, &opts)
+ defer face.Close()
+
+ textWidth := font.MeasureString(face, text).Round()
+ if align == AlignCenter {
+ x -= float64(textWidth) / 2
+ } else if align == AlignRight {
+ x -= float64(textWidth)
+ }
+ }
+
+ pt := freetype.Pt(int(x), int(y))
+ _, err := ctx.DrawString(text, pt)
+ return err
+}
+
+// MeasureText returns the width of text in pixels
+func (c *Card) MeasureText(text string, size float64, bold bool) int {
+ f := regularFont
+ if bold {
+ f = boldFont
+ }
+ if f == nil {
+ return 0
+ }
+
+ opts := truetype.Options{Size: size, DPI: 72}
+ face := truetype.NewFace(f, &opts)
+ defer face.Close()
+
+ return font.MeasureString(face, text).Round()
+}
+
+// DrawTextWrapped draws text with word wrapping within maxWidth
+// Returns the Y position after the last line
+func (c *Card) DrawTextWrapped(text string, x, y float64, size float64, col color.Color, maxWidth int, bold bool) float64 {
+ words := splitWords(text)
+ if len(words) == 0 {
+ return y
+ }
+
+ lineHeight := size * 1.3
+ currentLine := ""
+ currentY := y
+
+ for _, word := range words {
+ testLine := currentLine
+ if testLine != "" {
+ testLine += " "
+ }
+ testLine += word
+
+ lineWidth := c.MeasureText(testLine, size, bold)
+ if lineWidth > maxWidth && currentLine != "" {
+ // Draw current line and start new one
+ c.DrawText(currentLine, x, currentY, size, col, AlignLeft, bold)
+ currentY += lineHeight
+ currentLine = word
+ } else {
+ currentLine = testLine
+ }
+ }
+
+ // Draw remaining text
+ if currentLine != "" {
+ c.DrawText(currentLine, x, currentY, size, col, AlignLeft, bold)
+ currentY += lineHeight
+ }
+
+ return currentY
+}
+
+// splitWords splits text into words
+func splitWords(text string) []string {
+ var words []string
+ current := ""
+ for _, r := range text {
+ if r == ' ' || r == '\t' || r == '\n' {
+ if current != "" {
+ words = append(words, current)
+ current = ""
+ }
+ } else {
+ current += string(r)
+ }
+ }
+ if current != "" {
+ words = append(words, current)
+ }
+ return words
+}
+
+// DrawImage draws an image at the specified position
+func (c *Card) DrawImage(img image.Image, x, y int) {
+ bounds := img.Bounds()
+ rect := image.Rect(x, y, x+bounds.Dx(), y+bounds.Dy())
+ draw.Draw(c.img, rect, img, bounds.Min, draw.Over)
+}
+
+// DrawCircularImage draws an image cropped to a circle
+func (c *Card) DrawCircularImage(img image.Image, x, y, diameter int) {
+ // Scale image to fit diameter
+ scaled := scaleImage(img, diameter, diameter)
+
+ // Create circular mask
+ mask := createCircleMask(diameter)
+
+ // Draw with mask
+ rect := image.Rect(x, y, x+diameter, y+diameter)
+ draw.DrawMask(c.img, rect, scaled, image.Point{}, mask, image.Point{}, draw.Over)
+}
+
+// FetchAndDrawCircularImage fetches an image from URL and draws it as a circle
+func (c *Card) FetchAndDrawCircularImage(url string, x, y, diameter int) error {
+ client := &http.Client{Timeout: 5 * time.Second}
+ resp, err := client.Get(url)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ img, _, err := image.Decode(resp.Body)
+ if err != nil {
+ return err
+ }
+
+ c.DrawCircularImage(img, x, y, diameter)
+ return nil
+}
+
+// DrawPlaceholderCircle draws a colored circle with a letter
+func (c *Card) DrawPlaceholderCircle(x, y, diameter int, bgColor, textColor color.Color, letter string) {
+ // Draw filled circle
+ radius := diameter / 2
+ centerX := x + radius
+ centerY := y + radius
+
+ for dy := -radius; dy <= radius; dy++ {
+ for dx := -radius; dx <= radius; dx++ {
+ if dx*dx+dy*dy <= radius*radius {
+ c.img.Set(centerX+dx, centerY+dy, bgColor)
+ }
+ }
+ }
+
+ // Draw letter in center
+ fontSize := float64(diameter) * 0.5
+ c.DrawText(letter, float64(centerX), float64(centerY)+fontSize/3, fontSize, textColor, AlignCenter, true)
+}
+
+// DrawRoundedRect draws a filled rounded rectangle
+func (c *Card) DrawRoundedRect(x, y, w, h, radius int, col color.Color) {
+ // Draw main rectangle (without corners)
+ for dy := radius; dy < h-radius; dy++ {
+ for dx := 0; dx < w; dx++ {
+ c.img.Set(x+dx, y+dy, col)
+ }
+ }
+ // Draw top and bottom strips (without corners)
+ for dy := 0; dy < radius; dy++ {
+ for dx := radius; dx < w-radius; dx++ {
+ c.img.Set(x+dx, y+dy, col)
+ c.img.Set(x+dx, y+h-1-dy, col)
+ }
+ }
+ // Draw rounded corners
+ for dy := 0; dy < radius; dy++ {
+ for dx := 0; dx < radius; dx++ {
+ // Check if point is within circle
+ cx := radius - dx - 1
+ cy := radius - dy - 1
+ if cx*cx+cy*cy <= radius*radius {
+ // Top-left
+ c.img.Set(x+dx, y+dy, col)
+ // Top-right
+ c.img.Set(x+w-1-dx, y+dy, col)
+ // Bottom-left
+ c.img.Set(x+dx, y+h-1-dy, col)
+ // Bottom-right
+ c.img.Set(x+w-1-dx, y+h-1-dy, col)
+ }
+ }
+ }
+}
+
+// DrawBadge draws a pill-shaped badge with text
+func (c *Card) DrawBadge(text string, x, y int, fontSize float64, bgColor, textColor color.Color) int {
+ // Measure text width
+ textWidth := c.MeasureText(text, fontSize, false)
+ paddingX := 12
+ paddingY := 6
+ height := int(fontSize) + paddingY*2
+ width := textWidth + paddingX*2
+ radius := height / 2
+
+ // Draw rounded background
+ c.DrawRoundedRect(x, y, width, height, radius, bgColor)
+
+ // Draw text centered in badge
+ textX := float64(x + paddingX)
+ textY := float64(y + paddingY + int(fontSize) - 2)
+ c.DrawText(text, textX, textY, fontSize, textColor, AlignLeft, false)
+
+ return width
+}
+
+// EncodePNG encodes the card as PNG to the writer
+func (c *Card) EncodePNG(w io.Writer) error {
+ return png.Encode(w, c.img)
+}
+
+// DrawAvatarOrPlaceholder draws a circular avatar from URL, falling back to placeholder
+func (c *Card) DrawAvatarOrPlaceholder(url string, x, y, size int, letter string) {
+ if url != "" {
+ if err := c.FetchAndDrawCircularImage(url, x, y, size); err == nil {
+ return
+ }
+ }
+ c.DrawPlaceholderCircle(x, y, size, ColorAccent, ColorText, letter)
+}
+
+// DrawStatWithIcon draws an icon + text stat and returns the next X position
+func (c *Card) DrawStatWithIcon(icon string, text string, x, y int, iconColor, textColor color.Color) int {
+ c.DrawIcon(icon, x, y-int(FontStats), int(FontStats), iconColor)
+ x += StatsIconGap
+ c.DrawText(text, float64(x), float64(y), FontStats, textColor, AlignLeft, false)
+ return x + c.MeasureText(text, FontStats, false) + StatsItemGap
+}
+
+// DrawBranding draws "ATCR" in the bottom-right corner
+func (c *Card) DrawBranding() {
+ y := CardHeight - Padding - 10
+ c.DrawText("ATCR", float64(CardWidth-Padding), float64(y), FontBranding, ColorMuted, AlignRight, true)
+}
+
+// scaleImage scales an image to the target dimensions
+func scaleImage(src image.Image, width, height int) image.Image {
+ dst := image.NewRGBA(image.Rect(0, 0, width, height))
+ xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil)
+ return dst
+}
+
+// createCircleMask creates a circular alpha mask
+func createCircleMask(diameter int) *image.Alpha {
+ mask := image.NewAlpha(image.Rect(0, 0, diameter, diameter))
+ radius := diameter / 2
+ centerX := radius
+ centerY := radius
+
+ for y := 0; y < diameter; y++ {
+ for x := 0; x < diameter; x++ {
+ dx := x - centerX
+ dy := y - centerY
+ if dx*dx+dy*dy <= radius*radius {
+ mask.SetAlpha(x, y, color.Alpha{A: 255})
+ }
+ }
+ }
+
+ return mask
+}
+
+// Common colors
+var (
+ ColorBackground = color.RGBA{R: 13, G: 17, B: 23, A: 255} // #0d1117 - GitHub dark
+ ColorText = color.RGBA{R: 230, G: 237, B: 243, A: 255} // #e6edf3 - Light text
+ ColorMuted = color.RGBA{R: 125, G: 133, B: 144, A: 255} // #7d8590 - Muted text
+ ColorAccent = color.RGBA{R: 47, G: 129, B: 247, A: 255} // #2f81f7 - Blue accent
+ ColorStar = color.RGBA{R: 227, G: 179, B: 65, A: 255} // #e3b341 - Star yellow
+ ColorBadgeBg = color.RGBA{R: 33, G: 38, B: 45, A: 255} // #21262d - Badge background
+ ColorBadgeAccent = color.RGBA{R: 31, G: 111, B: 235, A: 255} // #1f6feb - Blue badge bg
+)
diff --git a/pkg/appview/ogcard/font.go b/pkg/appview/ogcard/font.go
new file mode 100644
index 0000000..619bf4b
--- /dev/null
+++ b/pkg/appview/ogcard/font.go
@@ -0,0 +1,45 @@
+package ogcard
+
+// Font configuration for OG card rendering.
+// Currently uses Go fonts (embedded in golang.org/x/image).
+//
+// To use custom fonts instead, replace the init() below with:
+//
+// //go:embed MyFont-Regular.ttf
+// var regularFontData []byte
+// //go:embed MyFont-Bold.ttf
+// var boldFontData []byte
+//
+// func init() {
+// regularFont, _ = truetype.Parse(regularFontData)
+// boldFont, _ = truetype.Parse(boldFontData)
+// }
+
+import (
+ "log"
+
+ "github.com/goki/freetype/truetype"
+ "golang.org/x/image/font/gofont/gobold"
+ "golang.org/x/image/font/gofont/goregular"
+)
+
+var (
+ regularFont *truetype.Font
+ boldFont *truetype.Font
+)
+
+func init() {
+ var err error
+
+ regularFont, err = truetype.Parse(goregular.TTF)
+ if err != nil {
+ log.Printf("ogcard: failed to parse Go Regular font: %v", err)
+ return
+ }
+
+ boldFont, err = truetype.Parse(gobold.TTF)
+ if err != nil {
+ log.Printf("ogcard: failed to parse Go Bold font: %v", err)
+ return
+ }
+}
diff --git a/pkg/appview/ogcard/icons.go b/pkg/appview/ogcard/icons.go
new file mode 100644
index 0000000..64d7f23
--- /dev/null
+++ b/pkg/appview/ogcard/icons.go
@@ -0,0 +1,68 @@
+package ogcard
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "image/draw"
+ "strings"
+
+ "github.com/srwiley/oksvg"
+ "github.com/srwiley/rasterx"
+)
+
+// Lucide icons as SVG paths (simplified from Lucide icon set)
+// These are the path data for 24x24 viewBox icons
+var iconPaths = map[string]string{
+ // Star icon - outline
+ "star": `