From 5f19213e32b44273a64c3e8da4ad1f2ea2320735 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Thu, 18 Dec 2025 12:29:20 -0600 Subject: [PATCH] better open graph --- .air.toml | 27 ++ .gitignore | 1 + Dockerfile.appview | 27 +- Makefile | 18 +- docker-compose.yml | 16 +- go.mod | 8 +- go.sum | 24 +- pkg/appview/db/schema.go | 61 ++- pkg/appview/db/schema_test.go | 92 +++++ pkg/appview/handlers/common.go | 19 +- pkg/appview/handlers/opengraph.go | 193 +++++++++ pkg/appview/handlers/repository.go | 18 +- pkg/appview/handlers/user.go | 12 +- pkg/appview/ogcard/card.go | 413 ++++++++++++++++++++ pkg/appview/ogcard/font.go | 45 +++ pkg/appview/ogcard/icons.go | 68 ++++ pkg/appview/routes/routes.go | 9 + pkg/appview/templates/components/head.html | 8 - pkg/appview/templates/pages/repository.html | 7 + pkg/appview/templates/pages/user.html | 7 + pkg/hold/config.go | 2 +- 21 files changed, 989 insertions(+), 86 deletions(-) create mode 100644 .air.toml create mode 100644 pkg/appview/db/schema_test.go create mode 100644 pkg/appview/handlers/opengraph.go create mode 100644 pkg/appview/ogcard/card.go create mode 100644 pkg/appview/ogcard/font.go create mode 100644 pkg/appview/ogcard/icons.go 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": ``, + + // Star filled + "star-filled": ``, + + // Arrow down to line (download/pull icon) + "arrow-down-to-line": ``, + + // Package icon + "package": ``, +} + +// DrawIcon draws a Lucide icon at the specified position with the given size and color +func (c *Card) DrawIcon(name string, x, y, size int, col color.Color) error { + path, ok := iconPaths[name] + if !ok { + return fmt.Errorf("unknown icon: %s", name) + } + + // Build full SVG with color + r, g, b, _ := col.RGBA() + colorStr := fmt.Sprintf("rgb(%d,%d,%d)", r>>8, g>>8, b>>8) + path = strings.ReplaceAll(path, "currentColor", colorStr) + + svg := fmt.Sprintf(`%s`, path) + + // Parse SVG + icon, err := oksvg.ReadIconStream(bytes.NewReader([]byte(svg))) + if err != nil { + return fmt.Errorf("failed to parse icon SVG: %w", err) + } + + // Create target image for the icon + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) + + // Set up scanner for rasterization + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) + raster := rasterx.NewDasher(size, size, scanner) + + // Scale icon to target size + scale := float64(size) / 24.0 + icon.SetTarget(0, 0, float64(size), float64(size)) + icon.Draw(raster, scale) + + // Draw icon onto card + rect := image.Rect(x, y, x+size, y+size) + draw.Draw(c.img, rect, iconImg, image.Point{}, draw.Over) + + return nil +} diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index add22f6..e2d4afc 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -141,6 +141,15 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { }, ).ServeHTTP) + // OpenGraph image generation (public, cacheable) + router.Get("/og/u/{handle}", (&uihandlers.UserOGHandler{ + DB: deps.ReadOnlyDB, + }).ServeHTTP) + + router.Get("/og/r/{handle}/{repository}", (&uihandlers.RepoOGHandler{ + DB: deps.ReadOnlyDB, + }).ServeHTTP) + router.Get("/r/{handle}/{repository}", middleware.OptionalAuth(deps.SessionStore, deps.Database)( &uihandlers.RepositoryPageHandler{ DB: deps.ReadOnlyDB, diff --git a/pkg/appview/templates/components/head.html b/pkg/appview/templates/components/head.html index a8da80b..5957327 100644 --- a/pkg/appview/templates/components/head.html +++ b/pkg/appview/templates/components/head.html @@ -2,14 +2,6 @@ - - - - - - - - diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index f8982d2..e3b52e4 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -3,6 +3,13 @@ {{ if .Repository.Title }}{{ .Repository.Title }}{{ else }}{{ .Owner.Handle }}/{{ .Repository.Name }}{{ end }} - ATCR + + + + + + + {{ template "head" . }} diff --git a/pkg/appview/templates/pages/user.html b/pkg/appview/templates/pages/user.html index b40d0b3..e68961f 100644 --- a/pkg/appview/templates/pages/user.html +++ b/pkg/appview/templates/pages/user.html @@ -3,6 +3,13 @@ {{ .ViewedUser.Handle }} - ATCR + + + + + + + {{ template "head" . }} diff --git a/pkg/hold/config.go b/pkg/hold/config.go index 05693ff..a630551 100644 --- a/pkg/hold/config.go +++ b/pkg/hold/config.go @@ -111,7 +111,7 @@ func LoadConfigFromEnv() (*Config, error) { cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true" cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" cfg.Server.DisablePresignedURLs = os.Getenv("DISABLE_PRESIGNED_URLS") == "true" - cfg.Server.RelayEndpoint = getEnvOrDefault("HOLD_RELAY_ENDPOINT", "https://bsky.network") + cfg.Server.RelayEndpoint = os.Getenv("HOLD_RELAY_ENDPOINT") cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads