better open graph

This commit is contained in:
Evan Jarrett
2025-12-18 12:29:20 -06:00
parent afbc039751
commit 5f19213e32
21 changed files with 989 additions and 86 deletions
+27
View File
@@ -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
View File
@@ -1,6 +1,7 @@
# Binaries
bin/
dist/
tmp/
# Test artifacts
.atcr-pids
+20 -7
View File
@@ -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" \
+12 -6
View File
@@ -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
View File
@@ -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:
+6 -2
View File
@@ -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
+16 -8
View File
@@ -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=
+57 -4
View File
@@ -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
+92
View File
@@ -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])
}
}
})
}
}
+3 -16
View File
@@ -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,
}
}
+193
View File
@@ -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)
}
}
+1 -17
View File
@@ -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,
+1 -11
View File
@@ -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,
+413
View File
@@ -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
)
+45
View File
@@ -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
}
}
+68
View File
@@ -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
}
+9
View File
@@ -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>
+7
View File
@@ -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
View File
@@ -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