mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
better open graph
This commit is contained in:
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
# Binaries
|
||||
bin/
|
||||
dist/
|
||||
tmp/
|
||||
|
||||
# Test artifacts
|
||||
.atcr-pids
|
||||
|
||||
+20
-7
@@ -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" \
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+10
-6
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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": `<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"/>`,
|
||||
|
||||
// Star filled
|
||||
"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"/>`,
|
||||
|
||||
// Arrow down to line (download/pull icon)
|
||||
"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"/>`,
|
||||
|
||||
// Package icon
|
||||
"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"/>`,
|
||||
}
|
||||
|
||||
// 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(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">%s</svg>`, 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="{{ .OGTitle }}">
|
||||
<meta property="og:description" content="{{ .OGDescription }}">
|
||||
<meta property="og:image" content="{{ .OGImage }}">
|
||||
<meta property="og:type" content="{{ .OGType }}">
|
||||
<meta property="og:url" content="{{ .OGURL }}">
|
||||
<meta property="og:site_name" content="ATCR">
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ if .Repository.Title }}{{ .Repository.Title }}{{ else }}{{ .Owner.Handle }}/{{ .Repository.Name }}{{ end }} - ATCR</title>
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR">
|
||||
<meta property="og:description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image on ATCR{{ end }}">
|
||||
<meta property="og:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
|
||||
<meta property="og:site_name" content="ATCR">
|
||||
{{ template "head" . }}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ .ViewedUser.Handle }} - ATCR</title>
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="{{ .ViewedUser.Handle }} - ATCR">
|
||||
<meta property="og:description" content="Container images by {{ .ViewedUser.Handle }} on ATCR">
|
||||
<meta property="og:image" content="https://{{ .RegistryURL }}/og/u/{{ .ViewedUser.Handle }}">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:url" content="{{ .RegistryURL }}/u/{{ .ViewedUser.Handle }}">
|
||||
<meta property="og:site_name" content="ATCR">
|
||||
{{ template "head" . }}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user