commit d69342b2e9a0f6d795276e75aadd12757dcc540c
Author: Josh
Date: Fri Jan 9 00:16:46 2026 +0200
init
diff --git a/.air.toml b/.air.toml
new file mode 100644
index 0000000..47b189c
--- /dev/null
+++ b/.air.toml
@@ -0,0 +1,24 @@
+root = "."
+tmp_dir = "tmp"
+
+[build]
+cmd = "go build -o ./tmp/writekit ."
+full_bin = "./tmp/writekit"
+include_ext = ["go", "html", "css", "js"]
+exclude_dir = ["tmp", "node_modules", "studio/node_modules", ".git"]
+exclude_regex = ["_test\\.go"]
+delay = 500
+poll = true
+poll_interval = 1000
+
+[log]
+time = false
+
+[color]
+main = "magenta"
+watcher = "cyan"
+build = "yellow"
+runner = "green"
+
+[misc]
+clean_on_exit = true
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..43544e1
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,79 @@
+# =============================================================================
+# CORE (Required)
+# =============================================================================
+
+# PostgreSQL connection string
+DATABASE_URL=postgres://writekit:writekit@localhost:5432/writekit?sslmode=disable
+
+# Base domain for the platform (without protocol)
+DOMAIN=writekit.localhost
+
+# Full URL for OAuth callbacks and links
+BASE_URL=http://writekit.localhost
+
+# Session cookie encryption key (min 32 characters)
+SESSION_SECRET=change-this-to-a-random-32-char-string
+
+# =============================================================================
+# OAUTH (At least one required for login)
+# =============================================================================
+
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+
+DISCORD_CLIENT_ID=
+DISCORD_CLIENT_SECRET=
+
+# =============================================================================
+# STORAGE (Optional - for image uploads)
+# =============================================================================
+
+R2_ACCOUNT_ID=
+R2_ACCESS_KEY_ID=
+R2_SECRET_ACCESS_KEY=
+R2_BUCKET=
+R2_PUBLIC_URL=
+# Custom endpoint (for MinIO/local dev, leave empty for R2)
+R2_ENDPOINT=
+
+# =============================================================================
+# ANALYTICS (Optional - for Cloudflare analytics)
+# =============================================================================
+
+# Cloudflare API token with Analytics read permission
+CLOUDFLARE_API_TOKEN=
+
+# Cloudflare Zone ID for your domain
+CLOUDFLARE_ZONE_ID=
+
+# =============================================================================
+# BILLING (Optional - for paid memberships)
+# =============================================================================
+
+# LemonSqueezy - https://app.lemonsqueezy.com/settings/api
+LEMON_API_KEY=
+LEMON_STORE_ID=
+LEMON_WEBHOOK_SECRET=
+
+# Wise (for payouts) - https://wise.com/settings/api-tokens
+WISE_API_KEY=
+WISE_PROFILE_ID=
+
+# Minimum balance before payout (in cents, default: 1000 = $10)
+PAYOUT_THRESHOLD_CENTS=1000
+
+# =============================================================================
+# SERVER (Optional)
+# =============================================================================
+
+# Server port (default: 8080)
+PORT=8080
+
+# SQLite data directory for tenant databases (default: ./data)
+DATA_DIR=./data
+
+# Environment: prod, staging, or dev (affects some behaviors)
+ENV=dev
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6474fa2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+writekit
+writekit.exe
+studio/dist/
+*.db
+*.db-wal
+*.db-shm
+.env
+node_modules/
+.idea/
+.vscode/
+*.swp
+
+tmp
diff --git a/.woodpecker.yml b/.woodpecker.yml
new file mode 100644
index 0000000..7a5b594
--- /dev/null
+++ b/.woodpecker.yml
@@ -0,0 +1,43 @@
+when:
+ branch: [main, dev]
+ event: push
+
+steps:
+ - name: test
+ image: golang:1.24-alpine
+ commands:
+ - go test ./...
+
+ - name: build
+ image: docker:27-cli
+ environment:
+ - DOCKER_HOST=unix:///var/run/docker.sock
+ commands:
+ - docker build -t 10.0.0.3:5000/writekit:${CI_COMMIT_BRANCH} .
+ - docker push 10.0.0.3:5000/writekit:${CI_COMMIT_BRANCH}
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+
+ - name: deploy-staging
+ image: alpine
+ when:
+ branch: dev
+ commands:
+ - apk add --no-cache openssh-client
+ - mkdir -p ~/.ssh
+ - cp /mnt/ssh/deploy_key ~/.ssh/id_ed25519
+ - cp /mnt/ssh/known_hosts ~/.ssh/known_hosts
+ - chmod 600 ~/.ssh/id_ed25519
+ - ssh deploy@10.0.0.2 "cd /opt/writekit && docker compose pull writekit-staging && docker compose up -d writekit-staging"
+
+ - name: deploy-prod
+ image: alpine
+ when:
+ branch: main
+ commands:
+ - apk add --no-cache openssh-client
+ - mkdir -p ~/.ssh
+ - cp /mnt/ssh/deploy_key ~/.ssh/id_ed25519
+ - cp /mnt/ssh/known_hosts ~/.ssh/known_hosts
+ - chmod 600 ~/.ssh/id_ed25519
+ - ssh deploy@10.0.0.2 "cd /opt/writekit && docker compose pull writekit-prod && docker compose up -d writekit-prod"
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b4bb6ed
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,22 @@
+FROM node:22-alpine AS studio
+WORKDIR /app/studio
+COPY studio/package*.json ./
+RUN npm ci
+COPY studio/ ./
+RUN npm run build
+
+FROM golang:1.23-alpine AS builder
+WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+COPY --from=studio /app/studio/dist ./studio/dist
+RUN CGO_ENABLED=0 go build -o writekit .
+
+FROM alpine:3.21
+RUN apk add --no-cache ca-certificates
+WORKDIR /app
+COPY --from=builder /app/writekit .
+COPY --from=builder /app/internal/db/migrations ./internal/db/migrations
+EXPOSE 8080
+CMD ["./writekit"]
diff --git a/MONETIZATION.md b/MONETIZATION.md
new file mode 100644
index 0000000..007b343
--- /dev/null
+++ b/MONETIZATION.md
@@ -0,0 +1,121 @@
+# WriteKit Monetization
+
+## Tiers
+
+| Tier | Price | Billing |
+|------|-------|---------|
+| Free | $0 | - |
+| Pro | $5/mo or $49/year | Lemon Squeezy |
+
+## Feature Matrix
+
+| Feature | Free | Pro |
+|---------|------|-----|
+| Subdomain (you.writekit.dev) | Yes | Yes |
+| Custom domain | No | Yes |
+| "Powered by WriteKit" badge | Required | Hidden |
+| Analytics retention | 7 days | 90 days |
+| API requests | 100/hour | 1000/hour |
+| Webhooks | 3 endpoints | 10 endpoints |
+| Webhook deliveries | 100/day | 1000/day |
+| Plugins | 3 max | 10 max |
+| Plugin executions | 1000/day | 10000/day |
+| Posts | Unlimited | Unlimited |
+| Assets | Unlimited | Unlimited |
+| Comments/Reactions | Unlimited | Unlimited |
+
+## Upgrade Triggers
+
+1. **Custom domain** - Primary trigger, most valuable to users
+2. **Remove badge** - Secondary, vanity upgrade
+3. **Analytics depth** - 7 days feels limiting for serious bloggers
+4. **Rate limits** - For power users/headless CMS use case
+
+## Positioning
+
+**Tagline:** "Your blog, your domain, your data."
+
+**Key messages:**
+- No paywalls, no algorithms, no surprises
+- 70% cheaper than Ghost ($5 vs $18/mo)
+- Own your content, export anytime
+- API-first, developer-friendly
+
+## Implementation
+
+### Backend Config
+
+```go
+// internal/config/tiers.go
+type Tier string
+
+const (
+ TierFree Tier = "free"
+ TierPro Tier = "pro"
+)
+
+type TierConfig struct {
+ Price int // cents/month
+ AnnualPrice int // cents/year
+ CustomDomain bool
+ BadgeRequired bool
+ AnalyticsRetention int // days
+ APIRateLimit int // requests/hour
+ MaxWebhooks int
+ WebhookDeliveries int // per day
+ MaxPlugins int
+ PluginExecutions int // per day
+}
+
+var Tiers = map[Tier]TierConfig{
+ TierFree: {
+ Price: 0,
+ AnnualPrice: 0,
+ CustomDomain: false,
+ BadgeRequired: true,
+ AnalyticsRetention: 7,
+ APIRateLimit: 100,
+ MaxWebhooks: 3,
+ WebhookDeliveries: 100,
+ MaxPlugins: 3,
+ PluginExecutions: 1000,
+ },
+ TierPro: {
+ Price: 500, // $5
+ AnnualPrice: 4900, // $49
+ CustomDomain: true,
+ BadgeRequired: false,
+ AnalyticsRetention: 90,
+ APIRateLimit: 1000,
+ MaxWebhooks: 10,
+ WebhookDeliveries: 1000,
+ MaxPlugins: 10,
+ PluginExecutions: 10000,
+ },
+}
+```
+
+### Database
+
+Add to `tenants` table:
+```sql
+tier TEXT NOT NULL DEFAULT 'free'
+```
+
+### Frontend
+
+Update `BillingPage.tsx` with:
+- Current plan display
+- Feature comparison
+- Upgrade button → Lemon Squeezy checkout
+
+### Enforcement Points
+
+| Feature | File | Check |
+|---------|------|-------|
+| Custom domain | `server/domain.go` | Block if tier != pro |
+| Badge | Blog templates | Show if tier == free |
+| Analytics | `tenant/analytics.go` | Filter by retention days |
+| API rate limit | `server/middleware.go` | Rate limiter per tier |
+| Webhooks count | `tenant/webhooks.go` | Check on create |
+| Plugins count | `tenant/plugins.go` | Check on create |
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ae20054
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,22 @@
+.PHONY: dev build test clean studio
+
+dev:
+ go run ./cmd/writekit
+
+build: studio
+ go build -o writekit ./cmd/writekit
+
+test:
+ go test ./...
+
+clean:
+ rm -f writekit
+ rm -rf dist/studio
+
+studio:
+ cd studio && bun install && bun run build
+ mkdir -p dist/studio
+ cp -r studio/dist/* dist/studio/
+
+docker:
+ docker build -t writekit:local .
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c77669c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,61 @@
+# WriteKit - Local Development
+
+## Prerequisites
+- Docker & Docker Compose
+- GitHub OAuth App (for login)
+
+## Setup GitHub OAuth
+1. Go to https://github.com/settings/developers
+2. New OAuth App:
+ - Name: `WriteKit Local`
+ - Homepage: `http://writekit.lvh.me`
+ - Callback: `http://writekit.lvh.me/auth/github/callback`
+3. Copy Client ID and Secret
+
+## Run
+```bash
+# Set OAuth credentials
+export GITHUB_CLIENT_ID=your_client_id
+export GITHUB_CLIENT_SECRET=your_client_secret
+
+# Start
+docker compose up --build
+```
+
+Or create `.env` file:
+```
+GITHUB_CLIENT_ID=your_client_id
+GITHUB_CLIENT_SECRET=your_client_secret
+```
+
+## Access
+- **Platform**: http://writekit.lvh.me
+- **Traefik dashboard**: http://localhost:8080
+- **MinIO console**: http://localhost:9001 (minioadmin/minioadmin)
+
+## Create a demo
+```bash
+curl -X POST http://writekit.lvh.me/api/demo
+```
+Returns subdomain like `demo-abc123.writekit.lvh.me` - works automatically, no hosts file needed.
+
+## Environment Variables
+
+### Required
+| Variable | Description |
+|----------|-------------|
+| `DATABASE_URL` | PostgreSQL connection string |
+| `DOMAIN` | Base domain |
+| `BASE_URL` | Full URL for OAuth callbacks |
+| `SESSION_SECRET` | Cookie encryption (32+ chars) |
+| `GITHUB_CLIENT_ID` | GitHub OAuth client ID |
+| `GITHUB_CLIENT_SECRET` | GitHub OAuth secret |
+
+### Optional
+| Variable | Description |
+|----------|-------------|
+| `GOOGLE_CLIENT_ID/SECRET` | Google OAuth |
+| `DISCORD_CLIENT_ID/SECRET` | Discord OAuth |
+| `R2_*` | Cloudflare R2 storage |
+| `IMAGINARY_URL` | Image processing service |
+| `CLOUDFLARE_API_TOKEN/ZONE_ID` | Analytics |
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f236df9
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,119 @@
+services:
+ traefik:
+ image: traefik:v3.2
+ command:
+ - --api.insecure=true
+ - --providers.docker=true
+ - --providers.docker.exposedbydefault=false
+ - --entrypoints.web.address=:80
+ ports:
+ - "80:80"
+ - "8888:8080"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: writekit
+ POSTGRES_PASSWORD: writekit
+ POSTGRES_DB: writekit
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U writekit"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ imaginary:
+ image: h2non/imaginary:latest
+ command: -enable-url-source=false -max-allowed-size=15728640
+
+ minio:
+ image: minio/minio:latest
+ command: server /data --console-address ":9001"
+ environment:
+ MINIO_ROOT_USER: minioadmin
+ MINIO_ROOT_PASSWORD: minioadmin
+ ports:
+ - "9000:9000"
+ - "9001:9001"
+ volumes:
+ - minio_data:/data
+
+ minio-init:
+ image: minio/mc:latest
+ depends_on:
+ - minio
+ entrypoint: >
+ /bin/sh -c "
+ sleep 2;
+ mc alias set local http://minio:9000 minioadmin minioadmin;
+ mc mb local/writekit --ignore-existing;
+ mc anonymous set download local/writekit;
+ exit 0;
+ "
+
+ vite:
+ image: node:20-alpine
+ working_dir: /app/studio
+ environment:
+ - CHOKIDAR_USEPOLLING=true
+ - CHOKIDAR_INTERVAL=100
+ command: sh -c "npm install && npm run dev -- --host"
+ volumes:
+ - ./studio:/app/studio
+ - vite_node_modules:/app/studio/node_modules
+
+ app:
+ image: cosmtrek/air
+ working_dir: /app
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ air_wd: /app
+ ENV: local
+ VITE_URL: http://vite:5173
+ DATABASE_URL: postgres://writekit:writekit@postgres:5432/writekit?sslmode=disable
+ DOMAIN: writekit.lvh.me
+ BASE_URL: http://writekit.lvh.me
+ DATA_DIR: /data
+ SESSION_SECRET: dev-secret-change-in-prod-must-be-32-chars
+ IMAGINARY_URL: http://imaginary:9000
+ R2_ACCOUNT_ID: local
+ R2_ACCESS_KEY_ID: minioadmin
+ R2_SECRET_ACCESS_KEY: minioadmin
+ R2_BUCKET: writekit
+ R2_PUBLIC_URL: http://localhost:9000/writekit
+ R2_ENDPOINT: http://minio:9000
+ GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
+ GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
+ JARVIS_URL: http://jarvis:8090
+ volumes:
+ - .:/app
+ - app_data:/data
+ - go_mod:/go/pkg/mod
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.app.rule=HostRegexp(`writekit.lvh.me`) || HostRegexp(`{subdomain:[a-z0-9-]+}.writekit.lvh.me`)
+ - traefik.http.routers.app.entrypoints=web
+ - traefik.http.services.app.loadbalancer.server.port=8080
+
+ jarvis:
+ build:
+ context: ../jarvis
+ ports:
+ - "8090:8090"
+ - "9999:9999"
+ environment:
+ PORT: "8090"
+ LSP_PORT: "9999"
+
+volumes:
+ postgres_data:
+ minio_data:
+ app_data:
+ go_mod:
+ vite_node_modules:
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..74e7d3a
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,67 @@
+module github.com/writekitapp/writekit
+
+go 1.24.0
+
+toolchain go1.24.2
+
+require (
+ github.com/alecthomas/chroma/v2 v2.21.1
+ github.com/aws/aws-sdk-go-v2 v1.32.2
+ github.com/aws/aws-sdk-go-v2/config v1.28.0
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.41
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.65.0
+ github.com/extism/go-sdk v1.7.1
+ github.com/go-chi/chi/v5 v5.1.0
+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
+ github.com/google/uuid v1.6.0
+ github.com/gorilla/websocket v1.5.3
+ github.com/jackc/pgx/v5 v5.7.1
+ github.com/yuin/goldmark v1.7.13
+ github.com/yuin/goldmark-emoji v1.0.6
+ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
+ golang.org/x/image v0.34.0
+ modernc.org/sqlite v1.34.0
+)
+
+require (
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.19 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 // indirect
+ github.com/aws/smithy-go v1.22.0 // indirect
+ github.com/dlclark/regexp2 v1.11.5 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect
+ github.com/gobwas/glob v0.2.3 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
+ github.com/tetratelabs/wazero v1.9.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.3.1 // indirect
+ golang.org/x/crypto v0.27.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.25.0 // indirect
+ golang.org/x/text v0.32.0 // indirect
+ google.golang.org/protobuf v1.34.2 // indirect
+ modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+ modernc.org/libc v1.55.3 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.8.0 // indirect
+ modernc.org/strutil v1.2.0 // indirect
+ modernc.org/token v1.1.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..68013e5
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,160 @@
+github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
+github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
+github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
+github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
+github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
+github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
+github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/aws/aws-sdk-go-v2 v1.32.2 h1:AkNLZEyYMLnx/Q/mSKkcMqwNFXMAvFto9bNsHqcTduI=
+github.com/aws/aws-sdk-go-v2 v1.32.2/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA=
+github.com/aws/aws-sdk-go-v2/config v1.28.0 h1:FosVYWcqEtWNxHn8gB/Vs6jOlNwSoyOCA/g/sxyySOQ=
+github.com/aws/aws-sdk-go-v2/config v1.28.0/go.mod h1:pYhbtvg1siOOg8h5an77rXle9tVG8T+BWLWAo7cOukc=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.41 h1:7gXo+Axmp+R4Z+AK8YFQO0ZV3L0gizGINCOWxSLY9W8=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.41/go.mod h1:u4Eb8d3394YLubphT4jLEwN1rLNq2wFOlT6OuxFwPzU=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 h1:TMH3f/SCAWdNtXXVPPu5D6wrr4G5hI1rAxbcocKfC7Q=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17/go.mod h1:1ZRXLdTpzdJb9fwTMXiLipENRxkGMTn1sfKexGllQCw=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 h1:UAsR3xA31QGf79WzpG/ixT9FZvQlh5HY1NRqSHBNOCk=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21/go.mod h1:JNr43NFf5L9YaG3eKTm7HQzls9J+A9YYcGI5Quh1r2Y=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 h1:6jZVETqmYCadGFvrYEQfC5fAQmlo80CeL5psbno6r0s=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21/go.mod h1:1SR0GbLlnN3QUmYaflZNiH1ql+1qrSiB2vwcJ+4UM60=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.19 h1:FKdiFzTxlTRO71p0C7VrLbkkdW8qfMKF5+ej6bTmkT0=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.19/go.mod h1:abO3pCj7WLQPTllnSeYImqFfkGrmJV0JovWo/gqT5N0=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.0 h1:FQNWhRuSq8QwW74GtU0MrveNhZbqvHsA4dkA9w8fTDQ=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.0/go.mod h1:j/zZ3zmWfGCK91K73YsfHP53BSTLSjL/y6YN39XbBLM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 h1:s7NA1SOw8q/5c0wr8477yOPp0z+uBaXBnLE0XYb0POA=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2/go.mod h1:fnjjWyAW/Pj5HYOxl9LJqWtEwS7W2qgcRLWP+uWbss0=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.0 h1:1NKXS8XfhMM0bg5wVYa/eOH8AM2f6JijugbKEyQFTIg=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.0/go.mod h1:ph931DUfVfgrhZR7py9olSvHCiRpvaGxNvlWBcXxFds=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.65.0 h1:2dSm7frMrw2tdJ0QvyccQNJyPGaP24dyDgZ6h1QJMGU=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.65.0/go.mod h1:4XSVpw66upN8wND3JZA29eXl2NOZvfFVq7DIP6xvfuQ=
+github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 h1:bSYXVyUzoTHoKalBmwaZxs97HU9DWWI3ehHSAMa7xOk=
+github.com/aws/aws-sdk-go-v2/service/sso v1.24.2/go.mod h1:skMqY7JElusiOUjMJMOv1jJsP7YUg7DrhgqZZWuzu1U=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 h1:AhmO1fHINP9vFYUE0LHzCWg/LfUWUF+zFPEcY9QXb7o=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2/go.mod h1:o8aQygT2+MVP0NaV6kbdE1YnnIM8RRVQzoeUH45GOdI=
+github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 h1:CiS7i0+FUe+/YY1GvIBLLrR/XNGZ4CtM1Ll0XavNuVo=
+github.com/aws/aws-sdk-go-v2/service/sts v1.32.2/go.mod h1:HtaiBI8CjYoNVde8arShXb94UbQQi9L4EMr6D+xGBwo=
+github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM=
+github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE=
+github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
+github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
+github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
+github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw=
+github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
+github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
+github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
+github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
+github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
+github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
+github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
+github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
+github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
+github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
+go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
+go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
+golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
+golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
+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/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
+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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
+golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+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/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-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
+modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
+modernc.org/sqlite v1.34.0 h1:wnIcc4XIGoWVkM9qGKn2PARAmpXsQWGebuOVOBYZZVY=
+modernc.org/sqlite v1.34.0/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go
new file mode 100644
index 0000000..f70e920
--- /dev/null
+++ b/internal/auth/middleware.go
@@ -0,0 +1,54 @@
+package auth
+
+import (
+ "context"
+ "net/http"
+ "strings"
+
+ "github.com/writekitapp/writekit/internal/db"
+)
+
+type ctxKey string
+
+const userIDKey ctxKey = "userID"
+
+func SessionMiddleware(database *db.DB) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ token := extractToken(r)
+ if token == "" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ session, err := database.ValidateSession(r.Context(), token)
+ if err != nil || session == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ ctx := context.WithValue(r.Context(), userIDKey, session.UserID)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+func GetUserID(r *http.Request) string {
+ if id, ok := r.Context().Value(userIDKey).(string); ok {
+ return id
+ }
+ return ""
+}
+
+func extractToken(r *http.Request) string {
+ if cookie, err := r.Cookie("writekit_session"); err == nil && cookie.Value != "" {
+ return cookie.Value
+ }
+
+ auth := r.Header.Get("Authorization")
+ if strings.HasPrefix(auth, "Bearer ") {
+ return strings.TrimPrefix(auth, "Bearer ")
+ }
+
+ return r.URL.Query().Get("token")
+}
diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go
new file mode 100644
index 0000000..890acae
--- /dev/null
+++ b/internal/auth/oauth.go
@@ -0,0 +1,535 @@
+package auth
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/writekitapp/writekit/internal/db"
+)
+
+type Handler struct {
+ database *db.DB
+ sessionSecret []byte
+ baseURL string
+ providers map[string]provider
+}
+
+type provider struct {
+ Name string
+ ClientID string
+ ClientSecret string
+ AuthURL string
+ TokenURL string
+ UserInfoURL string
+ Scopes []string
+}
+
+type oauthToken struct {
+ AccessToken string `json:"access_token"`
+}
+
+type oauthState struct {
+ Provider string `json:"p"`
+ TenantID string `json:"t,omitempty"`
+ Redirect string `json:"r,omitempty"`
+ Callback string `json:"c,omitempty"`
+ Timestamp int64 `json:"ts"`
+}
+
+type userInfo struct {
+ ID string
+ Email string
+ Name string
+ AvatarURL string
+}
+
+func NewHandler(database *db.DB) *Handler {
+ baseURL := os.Getenv("BASE_URL")
+ if baseURL == "" {
+ baseURL = "https://writekit.dev"
+ }
+
+ secret := os.Getenv("SESSION_SECRET")
+ if secret == "" {
+ secret = "dev-secret-change-in-production"
+ }
+
+ h := &Handler{
+ database: database,
+ sessionSecret: []byte(secret),
+ baseURL: baseURL,
+ providers: make(map[string]provider),
+ }
+
+ if id := os.Getenv("GOOGLE_CLIENT_ID"); id != "" {
+ h.providers["google"] = provider{
+ Name: "Google",
+ ClientID: id,
+ ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
+ AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
+ TokenURL: "https://oauth2.googleapis.com/token",
+ UserInfoURL: "https://www.googleapis.com/oauth2/v2/userinfo",
+ Scopes: []string{"email", "profile"},
+ }
+ }
+
+ if id := os.Getenv("GITHUB_CLIENT_ID"); id != "" {
+ h.providers["github"] = provider{
+ Name: "GitHub",
+ ClientID: id,
+ ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
+ AuthURL: "https://github.com/login/oauth/authorize",
+ TokenURL: "https://github.com/login/oauth/access_token",
+ UserInfoURL: "https://api.github.com/user",
+ Scopes: []string{"user:email"},
+ }
+ }
+
+ if id := os.Getenv("DISCORD_CLIENT_ID"); id != "" {
+ h.providers["discord"] = provider{
+ Name: "Discord",
+ ClientID: id,
+ ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"),
+ AuthURL: "https://discord.com/api/oauth2/authorize",
+ TokenURL: "https://discord.com/api/oauth2/token",
+ UserInfoURL: "https://discord.com/api/users/@me",
+ Scopes: []string{"identify", "email"},
+ }
+ }
+
+ return h
+}
+
+func (h *Handler) Routes() chi.Router {
+ r := chi.NewRouter()
+ r.Get("/google", h.initiate)
+ r.Get("/github", h.initiate)
+ r.Get("/discord", h.initiate)
+ r.Get("/callback", h.callback)
+ r.Get("/validate", h.validate)
+ r.Get("/user", h.user)
+ r.Get("/providers", h.listProviders)
+ r.Post("/logout", h.logout)
+ return r
+}
+
+func (h *Handler) initiate(w http.ResponseWriter, r *http.Request) {
+ providerName := strings.TrimPrefix(r.URL.Path, "/auth/")
+ if _, ok := h.providers[providerName]; !ok {
+ http.Error(w, "unknown provider", http.StatusBadRequest)
+ return
+ }
+
+ p := h.providers[providerName]
+ state := oauthState{
+ Provider: providerName,
+ TenantID: r.URL.Query().Get("tenant"),
+ Redirect: r.URL.Query().Get("redirect"),
+ Callback: r.URL.Query().Get("callback"),
+ Timestamp: time.Now().Unix(),
+ }
+
+ stateStr, err := h.encodeState(state)
+ if err != nil {
+ slog.Error("encode state", "error", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ authURL := fmt.Sprintf("%s?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s",
+ p.AuthURL,
+ url.QueryEscape(p.ClientID),
+ url.QueryEscape(h.baseURL+"/auth/callback"),
+ url.QueryEscape(strings.Join(p.Scopes, " ")),
+ url.QueryEscape(stateStr),
+ )
+
+ if providerName == "discord" {
+ authURL += "&prompt=consent"
+ }
+
+ http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
+}
+
+func (h *Handler) callback(w http.ResponseWriter, r *http.Request) {
+ code := r.URL.Query().Get("code")
+ stateStr := r.URL.Query().Get("state")
+
+ if code == "" || stateStr == "" {
+ http.Error(w, "missing code or state", http.StatusBadRequest)
+ return
+ }
+
+ state, err := h.decodeState(stateStr)
+ if err != nil {
+ slog.Error("decode state", "error", err)
+ http.Error(w, "invalid state", http.StatusBadRequest)
+ return
+ }
+
+ if time.Now().Unix()-state.Timestamp > 600 {
+ http.Error(w, "state expired", http.StatusBadRequest)
+ return
+ }
+
+ p, ok := h.providers[state.Provider]
+ if !ok {
+ http.Error(w, "unknown provider", http.StatusBadRequest)
+ return
+ }
+
+ token, err := h.exchangeCode(r.Context(), p, code)
+ if err != nil {
+ slog.Error("exchange code", "error", err)
+ http.Error(w, "auth failed", http.StatusInternalServerError)
+ return
+ }
+
+ info, err := h.getUserInfo(r.Context(), p, state.Provider, token)
+ if err != nil {
+ slog.Error("get user info", "error", err)
+ http.Error(w, "failed to get user info", http.StatusInternalServerError)
+ return
+ }
+
+ user, err := h.findOrCreateUser(r.Context(), state.Provider, info)
+ if err != nil {
+ slog.Error("find or create user", "error", err)
+ http.Error(w, "failed to create user", http.StatusInternalServerError)
+ return
+ }
+
+ session, err := h.database.CreateSession(r.Context(), user.ID)
+ if err != nil {
+ slog.Error("create session", "error", err)
+ http.Error(w, "failed to create session", http.StatusInternalServerError)
+ return
+ }
+
+ if state.Callback != "" {
+ callbackURL := state.Callback
+ if strings.Contains(callbackURL, "?") {
+ callbackURL += "&token=" + session.Token
+ } else {
+ callbackURL += "?token=" + session.Token
+ }
+ http.Redirect(w, r, callbackURL, http.StatusTemporaryRedirect)
+ return
+ }
+
+ redirect := state.Redirect
+ if redirect == "" || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
+ redirect = "/"
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "writekit_session",
+ Value: session.Token,
+ Path: "/",
+ Expires: session.ExpiresAt,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: !strings.Contains(h.baseURL, "localhost"),
+ })
+
+ http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
+}
+
+func (h *Handler) validate(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if token == "" {
+ http.Error(w, "missing token", http.StatusBadRequest)
+ return
+ }
+
+ session, err := h.database.ValidateSession(r.Context(), token)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if session == nil {
+ http.Error(w, "invalid session", http.StatusUnauthorized)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
+func (h *Handler) user(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if token == "" {
+ http.Error(w, "missing token", http.StatusBadRequest)
+ return
+ }
+
+ session, err := h.database.ValidateSession(r.Context(), token)
+ if err != nil || session == nil {
+ http.Error(w, "invalid session", http.StatusUnauthorized)
+ return
+ }
+
+ user, err := h.database.GetUserByID(r.Context(), session.UserID)
+ if err != nil || user == nil {
+ http.Error(w, "user not found", http.StatusNotFound)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "id": user.ID,
+ "email": user.Email,
+ "name": user.Name,
+ "avatar_url": user.AvatarURL,
+ })
+}
+
+func (h *Handler) listProviders(w http.ResponseWriter, r *http.Request) {
+ providers := []map[string]string{}
+ if _, ok := h.providers["google"]; ok {
+ providers = append(providers, map[string]string{"id": "google", "name": "Google"})
+ }
+ if _, ok := h.providers["github"]; ok {
+ providers = append(providers, map[string]string{"id": "github", "name": "GitHub"})
+ }
+ if _, ok := h.providers["discord"]; ok {
+ providers = append(providers, map[string]string{"id": "discord", "name": "Discord"})
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"providers": providers})
+}
+
+func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
+ cookie, err := r.Cookie("writekit_session")
+ if err == nil && cookie.Value != "" {
+ h.database.DeleteSession(r.Context(), cookie.Value)
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "writekit_session",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: !strings.Contains(h.baseURL, "localhost"),
+ })
+
+ w.WriteHeader(http.StatusOK)
+}
+
+func (h *Handler) exchangeCode(ctx context.Context, p provider, code string) (*oauthToken, error) {
+ data := url.Values{}
+ data.Set("client_id", p.ClientID)
+ data.Set("client_secret", p.ClientSecret)
+ data.Set("code", code)
+ data.Set("redirect_uri", h.baseURL+"/auth/callback")
+ data.Set("grant_type", "authorization_code")
+
+ req, err := http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("token exchange failed: %s", body)
+ }
+
+ var token oauthToken
+ if err := json.Unmarshal(body, &token); err != nil {
+ return nil, err
+ }
+ return &token, nil
+}
+
+func (h *Handler) getUserInfo(ctx context.Context, p provider, providerName string, token *oauthToken) (*userInfo, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", p.UserInfoURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+token.AccessToken)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("user info failed: %s", body)
+ }
+
+ var raw map[string]any
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return nil, err
+ }
+
+ info := &userInfo{}
+
+ switch providerName {
+ case "google":
+ info.ID = getString(raw, "id")
+ info.Email = getString(raw, "email")
+ info.Name = getString(raw, "name")
+ info.AvatarURL = getString(raw, "picture")
+
+ case "github":
+ info.ID = fmt.Sprintf("%v", raw["id"])
+ info.Email = getString(raw, "email")
+ info.Name = getString(raw, "name")
+ if info.Name == "" {
+ info.Name = getString(raw, "login")
+ }
+ info.AvatarURL = getString(raw, "avatar_url")
+
+ if info.Email == "" {
+ info.Email, _ = h.getGitHubEmail(ctx, token)
+ }
+
+ case "discord":
+ info.ID = getString(raw, "id")
+ info.Email = getString(raw, "email")
+ info.Name = getString(raw, "global_name")
+ if info.Name == "" {
+ info.Name = getString(raw, "username")
+ }
+ if avatar := getString(raw, "avatar"); avatar != "" {
+ info.AvatarURL = fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", info.ID, avatar)
+ }
+ }
+
+ if info.Email == "" {
+ return nil, fmt.Errorf("no email from provider")
+ }
+ return info, nil
+}
+
+func (h *Handler) getGitHubEmail(ctx context.Context, token *oauthToken) (string, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user/emails", nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+token.AccessToken)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ var emails []struct {
+ Email string `json:"email"`
+ Primary bool `json:"primary"`
+ Verified bool `json:"verified"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil {
+ return "", err
+ }
+
+ for _, e := range emails {
+ if e.Primary && e.Verified {
+ return e.Email, nil
+ }
+ }
+ for _, e := range emails {
+ if e.Verified {
+ return e.Email, nil
+ }
+ }
+ return "", nil
+}
+
+func (h *Handler) findOrCreateUser(ctx context.Context, providerName string, info *userInfo) (*db.User, error) {
+ user, err := h.database.GetUserByIdentity(ctx, providerName, info.ID)
+ if err != nil {
+ return nil, err
+ }
+ if user != nil {
+ return user, nil
+ }
+
+ user, err = h.database.GetUserByEmail(ctx, info.Email)
+ if err != nil {
+ return nil, err
+ }
+ if user != nil {
+ h.database.AddUserIdentity(ctx, user.ID, providerName, info.ID, info.Email)
+ return user, nil
+ }
+
+ user, err = h.database.CreateUser(ctx, info.Email, info.Name, info.AvatarURL)
+ if err != nil {
+ return nil, err
+ }
+ h.database.AddUserIdentity(ctx, user.ID, providerName, info.ID, info.Email)
+ return user, nil
+}
+
+func (h *Handler) encodeState(state oauthState) (string, error) {
+ data, err := json.Marshal(state)
+ if err != nil {
+ return "", err
+ }
+ mac := hmac.New(sha256.New, h.sessionSecret)
+ mac.Write(data)
+ sig := mac.Sum(nil)
+ return base64.URLEncoding.EncodeToString(append(data, sig...)), nil
+}
+
+func (h *Handler) decodeState(s string) (*oauthState, error) {
+ payload, err := base64.URLEncoding.DecodeString(s)
+ if err != nil {
+ return nil, err
+ }
+ if len(payload) < 32 {
+ return nil, fmt.Errorf("invalid state")
+ }
+
+ data := payload[:len(payload)-32]
+ sig := payload[len(payload)-32:]
+
+ mac := hmac.New(sha256.New, h.sessionSecret)
+ mac.Write(data)
+ if !hmac.Equal(sig, mac.Sum(nil)) {
+ return nil, fmt.Errorf("invalid signature")
+ }
+
+ var state oauthState
+ if err := json.Unmarshal(data, &state); err != nil {
+ return nil, err
+ }
+ return &state, nil
+}
+
+func getString(m map[string]any, key string) string {
+ if v, ok := m[key]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
diff --git a/internal/billing/lemon.go b/internal/billing/lemon.go
new file mode 100644
index 0000000..0df1b95
--- /dev/null
+++ b/internal/billing/lemon.go
@@ -0,0 +1,234 @@
+package billing
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+)
+
+const lemonBaseURL = "https://api.lemonsqueezy.com/v1"
+
+type LemonClient struct {
+ apiKey string
+ storeID string
+ webhookSecret string
+ httpClient *http.Client
+}
+
+func NewLemonClient() *LemonClient {
+ return &LemonClient{
+ apiKey: os.Getenv("LEMON_API_KEY"),
+ storeID: os.Getenv("LEMON_STORE_ID"),
+ webhookSecret: os.Getenv("LEMON_WEBHOOK_SECRET"),
+ httpClient: &http.Client{},
+ }
+}
+
+func (c *LemonClient) IsConfigured() bool {
+ return c.apiKey != "" && c.storeID != ""
+}
+
+type CheckoutRequest struct {
+ StoreID string `json:"store_id"`
+ VariantID string `json:"variant_id"`
+ CustomPrice *int `json:"custom_price,omitempty"`
+ CheckoutOptions *CheckoutOptions `json:"checkout_options,omitempty"`
+ CheckoutData *CheckoutData `json:"checkout_data,omitempty"`
+}
+
+type CheckoutOptions struct {
+ Embed bool `json:"embed,omitempty"`
+}
+
+type CheckoutData struct {
+ Email string `json:"email,omitempty"`
+ Name string `json:"name,omitempty"`
+ Custom map[string]string `json:"custom,omitempty"`
+}
+
+type CheckoutResponse struct {
+ Data struct {
+ ID string `json:"id"`
+ Attributes struct {
+ URL string `json:"url"`
+ } `json:"attributes"`
+ } `json:"data"`
+}
+
+func (c *LemonClient) CreateCheckout(ctx context.Context, req *CheckoutRequest) (*CheckoutResponse, error) {
+ if req.StoreID == "" {
+ req.StoreID = c.storeID
+ }
+
+ payload := map[string]any{
+ "data": map[string]any{
+ "type": "checkouts",
+ "attributes": req,
+ "relationships": map[string]any{
+ "store": map[string]any{
+ "data": map[string]any{"type": "stores", "id": req.StoreID},
+ },
+ "variant": map[string]any{
+ "data": map[string]any{"type": "variants", "id": req.VariantID},
+ },
+ },
+ },
+ }
+
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ httpReq, err := http.NewRequestWithContext(ctx, "POST", lemonBaseURL+"/checkouts", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+
+ httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
+ httpReq.Header.Set("Content-Type", "application/vnd.api+json")
+ httpReq.Header.Set("Accept", "application/vnd.api+json")
+
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("checkout creation failed (status %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ var checkoutResp CheckoutResponse
+ if err := json.Unmarshal(respBody, &checkoutResp); err != nil {
+ return nil, err
+ }
+
+ return &checkoutResp, nil
+}
+
+func (c *LemonClient) CreateSubscriptionCheckout(ctx context.Context, variantID, email, name string, custom map[string]string) (string, error) {
+ req := &CheckoutRequest{
+ VariantID: variantID,
+ CheckoutData: &CheckoutData{
+ Email: email,
+ Name: name,
+ Custom: custom,
+ },
+ CheckoutOptions: &CheckoutOptions{Embed: true},
+ }
+
+ resp, err := c.CreateCheckout(ctx, req)
+ if err != nil {
+ return "", err
+ }
+
+ return resp.Data.Attributes.URL, nil
+}
+
+func (c *LemonClient) CreateDonationCheckout(ctx context.Context, variantID string, amountCents int, email, name, message string, custom map[string]string) (string, error) {
+ if custom == nil {
+ custom = make(map[string]string)
+ }
+ if message != "" {
+ custom["message"] = message
+ }
+
+ req := &CheckoutRequest{
+ VariantID: variantID,
+ CustomPrice: &amountCents,
+ CheckoutData: &CheckoutData{
+ Email: email,
+ Name: name,
+ Custom: custom,
+ },
+ CheckoutOptions: &CheckoutOptions{Embed: true},
+ }
+
+ resp, err := c.CreateCheckout(ctx, req)
+ if err != nil {
+ return "", err
+ }
+
+ return resp.Data.Attributes.URL, nil
+}
+
+func (c *LemonClient) VerifyWebhook(payload []byte, signature string) bool {
+ if c.webhookSecret == "" {
+ return false
+ }
+
+ mac := hmac.New(sha256.New, []byte(c.webhookSecret))
+ mac.Write(payload)
+ expectedSig := hex.EncodeToString(mac.Sum(nil))
+
+ return hmac.Equal([]byte(signature), []byte(expectedSig))
+}
+
+type WebhookEvent struct {
+ Meta struct {
+ EventName string `json:"event_name"`
+ CustomData map[string]string `json:"custom_data"`
+ } `json:"meta"`
+ Data json.RawMessage `json:"data"`
+}
+
+type SubscriptionData struct {
+ ID string `json:"id"`
+ Attributes struct {
+ CustomerID int `json:"customer_id"`
+ VariantName string `json:"variant_name"`
+ UserName string `json:"user_name"`
+ UserEmail string `json:"user_email"`
+ Status string `json:"status"`
+ RenewsAt string `json:"renews_at"`
+ FirstSubscriptionItem struct {
+ Price int `json:"price"`
+ } `json:"first_subscription_item"`
+ } `json:"attributes"`
+}
+
+type OrderData struct {
+ ID string `json:"id"`
+ Attributes struct {
+ UserName string `json:"user_name"`
+ UserEmail string `json:"user_email"`
+ TotalUsd int `json:"total_usd"`
+ } `json:"attributes"`
+}
+
+func (c *LemonClient) ParseWebhookEvent(payload []byte) (*WebhookEvent, error) {
+ var event WebhookEvent
+ if err := json.Unmarshal(payload, &event); err != nil {
+ return nil, err
+ }
+ return &event, nil
+}
+
+func (e *WebhookEvent) GetSubscriptionData() (*SubscriptionData, error) {
+ var data SubscriptionData
+ if err := json.Unmarshal(e.Data, &data); err != nil {
+ return nil, err
+ }
+ return &data, nil
+}
+
+func (e *WebhookEvent) GetOrderData() (*OrderData, error) {
+ var data OrderData
+ if err := json.Unmarshal(e.Data, &data); err != nil {
+ return nil, err
+ }
+ return &data, nil
+}
diff --git a/internal/billing/payout.go b/internal/billing/payout.go
new file mode 100644
index 0000000..4361699
--- /dev/null
+++ b/internal/billing/payout.go
@@ -0,0 +1,126 @@
+package billing
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "strconv"
+ "time"
+)
+
+type PayoutWorker struct {
+ store *Store
+ wise *WiseClient
+ thresholdCents int
+ interval time.Duration
+}
+
+func NewPayoutWorker(store *Store, wiseClient *WiseClient) *PayoutWorker {
+ threshold := 1000
+ if v := os.Getenv("PAYOUT_THRESHOLD_CENTS"); v != "" {
+ if t, err := strconv.Atoi(v); err == nil {
+ threshold = t
+ }
+ }
+
+ return &PayoutWorker{
+ store: store,
+ wise: wiseClient,
+ thresholdCents: threshold,
+ interval: 5 * time.Minute,
+ }
+}
+
+func (w *PayoutWorker) Start(ctx context.Context) {
+ if !w.wise.IsConfigured() {
+ slog.Warn("wise not configured, payouts disabled")
+ return
+ }
+
+ ticker := time.NewTicker(w.interval)
+ defer ticker.Stop()
+
+ slog.Info("payout worker started", "threshold_cents", w.thresholdCents, "interval", w.interval)
+
+ for {
+ select {
+ case <-ctx.Done():
+ slog.Info("payout worker stopped")
+ return
+ case <-ticker.C:
+ w.processPayouts(ctx)
+ }
+ }
+}
+
+func (w *PayoutWorker) processPayouts(ctx context.Context) {
+ balances, err := w.store.GetTenantsReadyForPayout(ctx, w.thresholdCents)
+ if err != nil {
+ slog.Error("failed to get tenants ready for payout", "error", err)
+ return
+ }
+
+ for _, balance := range balances {
+ if err := w.processPayout(ctx, balance.TenantID, balance.AvailableCents); err != nil {
+ slog.Error("failed to process payout",
+ "tenant_id", balance.TenantID,
+ "amount_cents", balance.AvailableCents,
+ "error", err,
+ )
+ continue
+ }
+ }
+}
+
+func (w *PayoutWorker) processPayout(ctx context.Context, tenantID string, amountCents int) error {
+ settings, err := w.store.GetPayoutSettings(ctx, tenantID)
+ if err != nil {
+ return fmt.Errorf("get payout settings: %w", err)
+ }
+ if settings == nil || settings.WiseRecipientID == "" {
+ return fmt.Errorf("payout not configured for tenant")
+ }
+
+ recipientID, err := strconv.ParseInt(settings.WiseRecipientID, 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid recipient ID: %w", err)
+ }
+
+ quote, err := w.wise.CreateQuote(ctx, "USD", settings.Currency, amountCents)
+ if err != nil {
+ return fmt.Errorf("create quote: %w", err)
+ }
+
+ reference := fmt.Sprintf("WK-%s-%d", tenantID[:8], time.Now().Unix())
+
+ transfer, err := w.wise.CreateTransfer(ctx, quote.ID, recipientID, reference)
+ if err != nil {
+ return fmt.Errorf("create transfer: %w", err)
+ }
+
+ payoutID, err := w.store.CreatePayout(ctx, tenantID, amountCents, settings.Currency, fmt.Sprintf("%d", transfer.ID), quote.ID, "pending")
+ if err != nil {
+ return fmt.Errorf("record payout: %w", err)
+ }
+
+ if err := w.wise.FundTransfer(ctx, transfer.ID); err != nil {
+ w.store.UpdatePayoutStatus(ctx, payoutID, "failed", nil, err.Error())
+ return fmt.Errorf("fund transfer: %w", err)
+ }
+
+ if err := w.store.DeductBalance(ctx, tenantID, amountCents); err != nil {
+ return fmt.Errorf("deduct balance: %w", err)
+ }
+
+ w.store.UpdatePayoutStatus(ctx, payoutID, "processing", nil, "")
+
+ slog.Info("payout initiated",
+ "tenant_id", tenantID,
+ "amount_cents", amountCents,
+ "transfer_id", transfer.ID,
+ "payout_id", payoutID,
+ )
+
+ return nil
+}
diff --git a/internal/billing/store.go b/internal/billing/store.go
new file mode 100644
index 0000000..0b64e15
--- /dev/null
+++ b/internal/billing/store.go
@@ -0,0 +1,333 @@
+package billing
+
+import (
+ "context"
+ "database/sql"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Store struct {
+ db *pgxpool.Pool
+}
+
+func NewStore(db *pgxpool.Pool) *Store {
+ return &Store{db: db}
+}
+
+type Tier struct {
+ ID string
+ TenantID string
+ Name string
+ PriceCents int
+ Description string
+ LemonVariantID string
+ Active bool
+ CreatedAt time.Time
+}
+
+type Subscription struct {
+ ID string
+ TenantID string
+ UserID string
+ TierID string
+ TierName string
+ Status string
+ LemonSubscriptionID string
+ LemonCustomerID string
+ AmountCents int
+ CurrentPeriodStart time.Time
+ CurrentPeriodEnd time.Time
+ CancelledAt *time.Time
+ CreatedAt time.Time
+}
+
+type Donation struct {
+ ID string
+ TenantID string
+ UserID string
+ DonorEmail string
+ DonorName string
+ AmountCents int
+ LemonOrderID string
+ Message string
+ CreatedAt time.Time
+}
+
+type Balance struct {
+ TenantID string
+ AvailableCents int
+ LifetimeEarningsCents int
+ LifetimePaidCents int
+ UpdatedAt time.Time
+}
+
+type PayoutSettings struct {
+ TenantID string
+ WiseRecipientID string
+ AccountHolderName string
+ Currency string
+ PayoutEmail string
+}
+
+func (s *Store) CreateTier(ctx context.Context, tier *Tier) error {
+ _, err := s.db.Exec(ctx, `
+ INSERT INTO membership_tiers (tenant_id, name, price_cents, description, lemon_variant_id, active)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ `, tier.TenantID, tier.Name, tier.PriceCents, tier.Description, tier.LemonVariantID, tier.Active)
+ return err
+}
+
+func (s *Store) GetTiersByTenant(ctx context.Context, tenantID string) ([]Tier, error) {
+ rows, err := s.db.Query(ctx, `
+ SELECT id, tenant_id, name, price_cents, description, lemon_variant_id, active, created_at
+ FROM membership_tiers
+ WHERE tenant_id = $1 AND active = TRUE
+ ORDER BY price_cents ASC
+ `, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tiers []Tier
+ for rows.Next() {
+ var t Tier
+ if err := rows.Scan(&t.ID, &t.TenantID, &t.Name, &t.PriceCents, &t.Description, &t.LemonVariantID, &t.Active, &t.CreatedAt); err != nil {
+ return nil, err
+ }
+ tiers = append(tiers, t)
+ }
+ return tiers, rows.Err()
+}
+
+func (s *Store) CreateSubscription(ctx context.Context, sub *Subscription) error {
+ _, err := s.db.Exec(ctx, `
+ INSERT INTO subscriptions (tenant_id, user_id, tier_id, tier_name, status, lemon_subscription_id, lemon_customer_id, amount_cents, current_period_start, current_period_end)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ `, sub.TenantID, sub.UserID, sub.TierID, sub.TierName, sub.Status, sub.LemonSubscriptionID, sub.LemonCustomerID, sub.AmountCents, sub.CurrentPeriodStart, sub.CurrentPeriodEnd)
+ return err
+}
+
+func (s *Store) GetSubscriptionByLemonID(ctx context.Context, lemonSubID string) (*Subscription, error) {
+ var sub Subscription
+ var userID, tierID sql.NullString
+ var cancelledAt sql.NullTime
+ var periodStart, periodEnd sql.NullTime
+
+ err := s.db.QueryRow(ctx, `
+ SELECT id, tenant_id, user_id, tier_id, tier_name, status, lemon_subscription_id, lemon_customer_id,
+ amount_cents, current_period_start, current_period_end, cancelled_at, created_at
+ FROM subscriptions WHERE lemon_subscription_id = $1
+ `, lemonSubID).Scan(&sub.ID, &sub.TenantID, &userID, &tierID, &sub.TierName, &sub.Status,
+ &sub.LemonSubscriptionID, &sub.LemonCustomerID, &sub.AmountCents, &periodStart, &periodEnd,
+ &cancelledAt, &sub.CreatedAt)
+
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ sub.UserID = userID.String
+ sub.TierID = tierID.String
+ if periodStart.Valid {
+ sub.CurrentPeriodStart = periodStart.Time
+ }
+ if periodEnd.Valid {
+ sub.CurrentPeriodEnd = periodEnd.Time
+ }
+ if cancelledAt.Valid {
+ sub.CancelledAt = &cancelledAt.Time
+ }
+ return &sub, nil
+}
+
+func (s *Store) UpdateSubscriptionStatus(ctx context.Context, lemonSubID, status string, renewsAt *time.Time) error {
+ _, err := s.db.Exec(ctx, `
+ UPDATE subscriptions
+ SET status = $1, current_period_end = $2, updated_at = NOW()
+ WHERE lemon_subscription_id = $3
+ `, status, renewsAt, lemonSubID)
+ return err
+}
+
+func (s *Store) CancelSubscription(ctx context.Context, lemonSubID string) error {
+ _, err := s.db.Exec(ctx, `
+ UPDATE subscriptions
+ SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW()
+ WHERE lemon_subscription_id = $1
+ `, lemonSubID)
+ return err
+}
+
+func (s *Store) CreateDonation(ctx context.Context, donation *Donation) error {
+ err := s.db.QueryRow(ctx, `
+ INSERT INTO donations (tenant_id, user_id, donor_email, donor_name, amount_cents, lemon_order_id, message)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING id
+ `, donation.TenantID, donation.UserID, donation.DonorEmail, donation.DonorName, donation.AmountCents, donation.LemonOrderID, donation.Message).Scan(&donation.ID)
+ return err
+}
+
+func (s *Store) AddEarnings(ctx context.Context, tenantID, sourceType, sourceID, description string, grossCents, platformFeeCents, processorFeeCents, netCents int) error {
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+
+ _, err = tx.Exec(ctx, `
+ INSERT INTO earnings (tenant_id, source_type, source_id, description, gross_cents, platform_fee_cents, processor_fee_cents, net_cents)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ `, tenantID, sourceType, sourceID, description, grossCents, platformFeeCents, processorFeeCents, netCents)
+ if err != nil {
+ return err
+ }
+
+ _, err = tx.Exec(ctx, `
+ INSERT INTO balances (tenant_id, available_cents, lifetime_earnings_cents, updated_at)
+ VALUES ($1, $2, $2, NOW())
+ ON CONFLICT (tenant_id) DO UPDATE
+ SET available_cents = balances.available_cents + $2,
+ lifetime_earnings_cents = balances.lifetime_earnings_cents + $2,
+ updated_at = NOW()
+ `, tenantID, netCents)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+func (s *Store) GetBalance(ctx context.Context, tenantID string) (*Balance, error) {
+ var b Balance
+ err := s.db.QueryRow(ctx, `
+ SELECT tenant_id, available_cents, lifetime_earnings_cents, lifetime_paid_cents, updated_at
+ FROM balances WHERE tenant_id = $1
+ `, tenantID).Scan(&b.TenantID, &b.AvailableCents, &b.LifetimeEarningsCents, &b.LifetimePaidCents, &b.UpdatedAt)
+
+ if err == sql.ErrNoRows {
+ return &Balance{TenantID: tenantID}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &b, nil
+}
+
+func (s *Store) GetTenantsReadyForPayout(ctx context.Context, thresholdCents int) ([]Balance, error) {
+ rows, err := s.db.Query(ctx, `
+ SELECT b.tenant_id, b.available_cents, b.lifetime_earnings_cents, b.lifetime_paid_cents, b.updated_at
+ FROM balances b
+ JOIN payout_settings ps ON b.tenant_id = ps.tenant_id
+ WHERE b.available_cents >= $1 AND ps.wise_recipient_id IS NOT NULL
+ `, thresholdCents)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var balances []Balance
+ for rows.Next() {
+ var b Balance
+ if err := rows.Scan(&b.TenantID, &b.AvailableCents, &b.LifetimeEarningsCents, &b.LifetimePaidCents, &b.UpdatedAt); err != nil {
+ return nil, err
+ }
+ balances = append(balances, b)
+ }
+ return balances, rows.Err()
+}
+
+func (s *Store) DeductBalance(ctx context.Context, tenantID string, amountCents int) error {
+ _, err := s.db.Exec(ctx, `
+ UPDATE balances
+ SET available_cents = available_cents - $1,
+ lifetime_paid_cents = lifetime_paid_cents + $1,
+ updated_at = NOW()
+ WHERE tenant_id = $2 AND available_cents >= $1
+ `, amountCents, tenantID)
+ return err
+}
+
+func (s *Store) CreatePayout(ctx context.Context, tenantID string, amountCents int, currency, wiseTransferID, wiseQuoteID, status string) (string, error) {
+ var id string
+ err := s.db.QueryRow(ctx, `
+ INSERT INTO payouts (tenant_id, amount_cents, currency, wise_transfer_id, wise_quote_id, status)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id
+ `, tenantID, amountCents, currency, wiseTransferID, wiseQuoteID, status).Scan(&id)
+ return id, err
+}
+
+func (s *Store) UpdatePayoutStatus(ctx context.Context, payoutID, status string, completedAt *time.Time, failureReason string) error {
+ _, err := s.db.Exec(ctx, `
+ UPDATE payouts
+ SET status = $1, completed_at = $2, failure_reason = $3
+ WHERE id = $4
+ `, status, completedAt, failureReason, payoutID)
+ return err
+}
+
+func (s *Store) GetPayoutSettings(ctx context.Context, tenantID string) (*PayoutSettings, error) {
+ var ps PayoutSettings
+ var recipientID, holderName, currency, email sql.NullString
+
+ err := s.db.QueryRow(ctx, `
+ SELECT tenant_id, wise_recipient_id, account_holder_name, currency, payout_email
+ FROM payout_settings WHERE tenant_id = $1
+ `, tenantID).Scan(&ps.TenantID, &recipientID, &holderName, ¤cy, &email)
+
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ ps.WiseRecipientID = recipientID.String
+ ps.AccountHolderName = holderName.String
+ ps.Currency = currency.String
+ if ps.Currency == "" {
+ ps.Currency = "USD"
+ }
+ ps.PayoutEmail = email.String
+
+ return &ps, nil
+}
+
+func (s *Store) SavePayoutSettings(ctx context.Context, ps *PayoutSettings) error {
+ _, err := s.db.Exec(ctx, `
+ INSERT INTO payout_settings (tenant_id, wise_recipient_id, account_holder_name, currency, payout_email)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (tenant_id) DO UPDATE
+ SET wise_recipient_id = $2,
+ account_holder_name = $3,
+ currency = $4,
+ payout_email = $5,
+ updated_at = NOW()
+ `, ps.TenantID, ps.WiseRecipientID, ps.AccountHolderName, ps.Currency, ps.PayoutEmail)
+ return err
+}
+
+type User struct {
+ ID string
+ Email string
+ Name string
+}
+
+func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
+ var u User
+ err := s.db.QueryRow(ctx,
+ `SELECT id, email, COALESCE(name, '') FROM users WHERE id = $1`,
+ id).Scan(&u.ID, &u.Email, &u.Name)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
diff --git a/internal/billing/webhook.go b/internal/billing/webhook.go
new file mode 100644
index 0000000..a16533b
--- /dev/null
+++ b/internal/billing/webhook.go
@@ -0,0 +1,303 @@
+package billing
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+type MemberSyncer interface {
+ SyncMember(ctx context.Context, tenantID, userID, email, name, tier, status string, expiresAt *time.Time) error
+}
+
+type WebhookHandler struct {
+ store *Store
+ lemon *LemonClient
+ memberSyncer MemberSyncer
+}
+
+func NewWebhookHandler(store *Store, lemonClient *LemonClient, syncer MemberSyncer) *WebhookHandler {
+ return &WebhookHandler{
+ store: store,
+ lemon: lemonClient,
+ memberSyncer: syncer,
+ }
+}
+
+func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ slog.Error("failed to read webhook body", "error", err)
+ http.Error(w, "Failed to read body", http.StatusBadRequest)
+ return
+ }
+
+ signature := r.Header.Get("X-Signature")
+ if !h.lemon.VerifyWebhook(body, signature) {
+ slog.Warn("invalid webhook signature")
+ http.Error(w, "Invalid signature", http.StatusUnauthorized)
+ return
+ }
+
+ event, err := h.lemon.ParseWebhookEvent(body)
+ if err != nil {
+ slog.Error("failed to parse webhook event", "error", err)
+ http.Error(w, "Failed to parse event", http.StatusBadRequest)
+ return
+ }
+
+ ctx := r.Context()
+
+ switch event.Meta.EventName {
+ case "subscription_created":
+ err = h.handleSubscriptionCreated(ctx, event)
+ case "subscription_updated":
+ err = h.handleSubscriptionUpdated(ctx, event)
+ case "subscription_cancelled":
+ err = h.handleSubscriptionCancelled(ctx, event)
+ case "subscription_payment_success":
+ err = h.handleSubscriptionPayment(ctx, event)
+ case "order_created":
+ err = h.handleOrderCreated(ctx, event)
+ default:
+ slog.Debug("unhandled webhook event", "event", event.Meta.EventName)
+ }
+
+ if err != nil {
+ slog.Error("webhook handler error", "event", event.Meta.EventName, "error", err)
+ http.Error(w, "Handler error", http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
+func (h *WebhookHandler) handleSubscriptionCreated(ctx context.Context, event *WebhookEvent) error {
+ data, err := event.GetSubscriptionData()
+ if err != nil {
+ return fmt.Errorf("parse subscription data: %w", err)
+ }
+
+ tenantID := event.Meta.CustomData["tenant_id"]
+ userID := event.Meta.CustomData["user_id"]
+ tierID := event.Meta.CustomData["tier_id"]
+
+ if tenantID == "" {
+ return fmt.Errorf("missing tenant_id in custom data")
+ }
+
+ sub := &Subscription{
+ TenantID: tenantID,
+ UserID: userID,
+ TierID: tierID,
+ TierName: data.Attributes.VariantName,
+ Status: normalizeStatus(data.Attributes.Status),
+ LemonSubscriptionID: data.ID,
+ LemonCustomerID: strconv.Itoa(data.Attributes.CustomerID),
+ AmountCents: data.Attributes.FirstSubscriptionItem.Price,
+ }
+
+ if data.Attributes.RenewsAt != "" {
+ if t, err := time.Parse(time.RFC3339, data.Attributes.RenewsAt); err == nil {
+ sub.CurrentPeriodEnd = t
+ }
+ }
+ sub.CurrentPeriodStart = time.Now()
+
+ if err := h.store.CreateSubscription(ctx, sub); err != nil {
+ return fmt.Errorf("create subscription: %w", err)
+ }
+
+ if h.memberSyncer != nil && userID != "" {
+ user, _ := h.store.GetUserByID(ctx, userID)
+ email, name := "", ""
+ if user != nil {
+ email, name = user.Email, user.Name
+ }
+ if err := h.memberSyncer.SyncMember(ctx, tenantID, userID, email, name, sub.TierName, "active", &sub.CurrentPeriodEnd); err != nil {
+ slog.Error("sync member failed", "tenant_id", tenantID, "user_id", userID, "error", err)
+ }
+ }
+
+ slog.Info("subscription created",
+ "tenant_id", tenantID,
+ "lemon_id", data.ID,
+ "tier", data.Attributes.VariantName,
+ )
+
+ return nil
+}
+
+func (h *WebhookHandler) handleSubscriptionUpdated(ctx context.Context, event *WebhookEvent) error {
+ data, err := event.GetSubscriptionData()
+ if err != nil {
+ return fmt.Errorf("parse subscription data: %w", err)
+ }
+
+ var renewsAt *time.Time
+ if data.Attributes.RenewsAt != "" {
+ if t, err := time.Parse(time.RFC3339, data.Attributes.RenewsAt); err == nil {
+ renewsAt = &t
+ }
+ }
+
+ status := normalizeStatus(data.Attributes.Status)
+
+ if err := h.store.UpdateSubscriptionStatus(ctx, data.ID, status, renewsAt); err != nil {
+ return fmt.Errorf("update subscription: %w", err)
+ }
+
+ if h.memberSyncer != nil {
+ sub, _ := h.store.GetSubscriptionByLemonID(ctx, data.ID)
+ if sub != nil && sub.UserID != "" {
+ user, _ := h.store.GetUserByID(ctx, sub.UserID)
+ email, name := "", ""
+ if user != nil {
+ email, name = user.Email, user.Name
+ }
+ if err := h.memberSyncer.SyncMember(ctx, sub.TenantID, sub.UserID, email, name, sub.TierName, status, renewsAt); err != nil {
+ slog.Error("sync member failed", "tenant_id", sub.TenantID, "user_id", sub.UserID, "error", err)
+ }
+ }
+ }
+
+ slog.Info("subscription updated", "lemon_id", data.ID, "status", status)
+
+ return nil
+}
+
+func (h *WebhookHandler) handleSubscriptionCancelled(ctx context.Context, event *WebhookEvent) error {
+ data, err := event.GetSubscriptionData()
+ if err != nil {
+ return fmt.Errorf("parse subscription data: %w", err)
+ }
+
+ sub, _ := h.store.GetSubscriptionByLemonID(ctx, data.ID)
+
+ if err := h.store.CancelSubscription(ctx, data.ID); err != nil {
+ return fmt.Errorf("cancel subscription: %w", err)
+ }
+
+ if h.memberSyncer != nil && sub != nil && sub.UserID != "" {
+ user, _ := h.store.GetUserByID(ctx, sub.UserID)
+ email, name := "", ""
+ if user != nil {
+ email, name = user.Email, user.Name
+ }
+ if err := h.memberSyncer.SyncMember(ctx, sub.TenantID, sub.UserID, email, name, sub.TierName, "cancelled", nil); err != nil {
+ slog.Error("sync member failed", "tenant_id", sub.TenantID, "user_id", sub.UserID, "error", err)
+ }
+ }
+
+ slog.Info("subscription cancelled", "lemon_id", data.ID)
+
+ return nil
+}
+
+func (h *WebhookHandler) handleSubscriptionPayment(ctx context.Context, event *WebhookEvent) error {
+ data, err := event.GetSubscriptionData()
+ if err != nil {
+ return fmt.Errorf("parse subscription data: %w", err)
+ }
+
+ sub, err := h.store.GetSubscriptionByLemonID(ctx, data.ID)
+ if err != nil {
+ return fmt.Errorf("get subscription: %w", err)
+ }
+ if sub == nil {
+ return fmt.Errorf("subscription not found: %s", data.ID)
+ }
+
+ grossCents := data.Attributes.FirstSubscriptionItem.Price
+ platformFeeCents := grossCents * 5 / 100
+ processorFeeCents := grossCents * 5 / 100
+ netCents := grossCents - platformFeeCents - processorFeeCents
+
+ description := fmt.Sprintf("%s subscription - %s", sub.TierName, time.Now().Format("January 2006"))
+
+ if err := h.store.AddEarnings(ctx, sub.TenantID, "subscription_payment", sub.ID, description, grossCents, platformFeeCents, processorFeeCents, netCents); err != nil {
+ return fmt.Errorf("add earnings: %w", err)
+ }
+
+ slog.Info("subscription payment recorded",
+ "tenant_id", sub.TenantID,
+ "gross_cents", grossCents,
+ "net_cents", netCents,
+ )
+
+ return nil
+}
+
+func (h *WebhookHandler) handleOrderCreated(ctx context.Context, event *WebhookEvent) error {
+ data, err := event.GetOrderData()
+ if err != nil {
+ return fmt.Errorf("parse order data: %w", err)
+ }
+
+ tenantID := event.Meta.CustomData["tenant_id"]
+ userID := event.Meta.CustomData["user_id"]
+ message := event.Meta.CustomData["message"]
+
+ if tenantID == "" {
+ return fmt.Errorf("missing tenant_id in custom data")
+ }
+
+ if event.Meta.CustomData["type"] != "donation" {
+ return nil
+ }
+
+ donation := &Donation{
+ TenantID: tenantID,
+ UserID: userID,
+ DonorEmail: data.Attributes.UserEmail,
+ DonorName: data.Attributes.UserName,
+ AmountCents: data.Attributes.TotalUsd,
+ LemonOrderID: data.ID,
+ Message: message,
+ }
+
+ if err := h.store.CreateDonation(ctx, donation); err != nil {
+ return fmt.Errorf("create donation: %w", err)
+ }
+
+ grossCents := data.Attributes.TotalUsd
+ platformFeeCents := grossCents * 5 / 100
+ processorFeeCents := grossCents * 5 / 100
+ netCents := grossCents - platformFeeCents - processorFeeCents
+
+ description := fmt.Sprintf("Donation from %s", data.Attributes.UserName)
+
+ if err := h.store.AddEarnings(ctx, tenantID, "donation", donation.ID, description, grossCents, platformFeeCents, processorFeeCents, netCents); err != nil {
+ return fmt.Errorf("add earnings: %w", err)
+ }
+
+ slog.Info("donation recorded",
+ "tenant_id", tenantID,
+ "donor", data.Attributes.UserEmail,
+ "gross_cents", grossCents,
+ )
+
+ return nil
+}
+
+func normalizeStatus(lemonStatus string) string {
+ switch lemonStatus {
+ case "on_trial", "active":
+ return "active"
+ case "paused", "past_due", "unpaid":
+ return "past_due"
+ case "cancelled", "expired":
+ return "cancelled"
+ default:
+ return lemonStatus
+ }
+}
diff --git a/internal/billing/wise.go b/internal/billing/wise.go
new file mode 100644
index 0000000..c40a46f
--- /dev/null
+++ b/internal/billing/wise.go
@@ -0,0 +1,198 @@
+package billing
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+)
+
+const wiseBaseURL = "https://api.transferwise.com"
+
+type WiseClient struct {
+ apiKey string
+ profileID string
+ client *http.Client
+}
+
+func NewWiseClient() *WiseClient {
+ return &WiseClient{
+ apiKey: os.Getenv("WISE_API_KEY"),
+ profileID: os.Getenv("WISE_PROFILE_ID"),
+ client: &http.Client{},
+ }
+}
+
+func (c *WiseClient) IsConfigured() bool {
+ return c.apiKey != "" && c.profileID != ""
+}
+
+type Quote struct {
+ ID string `json:"id"`
+ SourceAmount float64 `json:"sourceAmount"`
+ TargetAmount float64 `json:"targetAmount"`
+ Rate float64 `json:"rate"`
+ Fee float64 `json:"fee"`
+ SourceCurrency string `json:"sourceCurrency"`
+ TargetCurrency string `json:"targetCurrency"`
+}
+
+type Transfer struct {
+ ID int64 `json:"id"`
+ Status string `json:"status"`
+ SourceValue float64 `json:"sourceValue"`
+ TargetValue float64 `json:"targetValue"`
+}
+
+func (c *WiseClient) CreateQuote(ctx context.Context, sourceCurrency, targetCurrency string, sourceAmountCents int) (*Quote, error) {
+ payload := map[string]any{
+ "sourceCurrency": sourceCurrency,
+ "targetCurrency": targetCurrency,
+ "sourceAmount": float64(sourceAmountCents) / 100,
+ "profile": c.profileID,
+ }
+
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", wiseBaseURL+"/v3/quotes", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ c.setHeaders(req)
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("quote creation failed (status %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ var quote Quote
+ if err := json.Unmarshal(respBody, "e); err != nil {
+ return nil, err
+ }
+
+ return "e, nil
+}
+
+func (c *WiseClient) CreateTransfer(ctx context.Context, quoteID string, recipientID int64, reference string) (*Transfer, error) {
+ payload := map[string]any{
+ "targetAccount": recipientID,
+ "quoteUuid": quoteID,
+ "customerTransactionId": reference,
+ "details": map[string]any{"reference": reference},
+ }
+
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", wiseBaseURL+"/v1/transfers", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ c.setHeaders(req)
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("transfer creation failed (status %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ var transfer Transfer
+ if err := json.Unmarshal(respBody, &transfer); err != nil {
+ return nil, err
+ }
+
+ return &transfer, nil
+}
+
+func (c *WiseClient) FundTransfer(ctx context.Context, transferID int64) error {
+ url := fmt.Sprintf("%s/v3/profiles/%s/transfers/%d/payments", wiseBaseURL, c.profileID, transferID)
+
+ payload := map[string]any{"type": "BALANCE"}
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ c.setHeaders(req)
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ respBody, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("funding failed (status %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ return nil
+}
+
+func (c *WiseClient) GetTransferStatus(ctx context.Context, transferID int64) (string, error) {
+ url := fmt.Sprintf("%s/v1/transfers/%d", wiseBaseURL, transferID)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return "", err
+ }
+ c.setHeaders(req)
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("get transfer failed (status %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ var transfer Transfer
+ if err := json.Unmarshal(respBody, &transfer); err != nil {
+ return "", err
+ }
+
+ return transfer.Status, nil
+}
+
+func (c *WiseClient) setHeaders(req *http.Request) {
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ req.Header.Set("Content-Type", "application/json")
+}
diff --git a/internal/build/assets/assets.go b/internal/build/assets/assets.go
new file mode 100644
index 0000000..a7eed10
--- /dev/null
+++ b/internal/build/assets/assets.go
@@ -0,0 +1,15 @@
+package assets
+
+import (
+ "embed"
+ "io/fs"
+ "net/http"
+)
+
+//go:embed css js
+var staticFS embed.FS
+
+func Handler() http.Handler {
+ sub, _ := fs.Sub(staticFS, ".")
+ return http.FileServer(http.FS(sub))
+}
diff --git a/internal/build/assets/css/style.css b/internal/build/assets/css/style.css
new file mode 100644
index 0000000..a52612e
--- /dev/null
+++ b/internal/build/assets/css/style.css
@@ -0,0 +1,778 @@
+/* WriteKit - Blog Stylesheet */
+
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+:root {
+ --accent: #2563eb;
+ --font-body: system-ui, -apple-system, sans-serif;
+ --font-mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
+
+ --text: #18181b;
+ --text-muted: #71717a;
+ --bg: #ffffff;
+ --bg-secondary: #fafafa;
+ --border: #e4e4e7;
+
+ --content-width: 680px;
+ --spacing: 1.5rem;
+
+ /* Compactness defaults (cozy) */
+ --content-spacing: 1.75rem;
+ --paragraph-spacing: 1.25rem;
+ --heading-spacing: 2.5rem;
+ --line-height: 1.7;
+}
+
+/* Compactness: Compact */
+.compactness-compact {
+ --content-spacing: 1.25rem;
+ --paragraph-spacing: 0.875rem;
+ --heading-spacing: 1.75rem;
+ --line-height: 1.55;
+}
+
+/* Compactness: Cozy (default) */
+.compactness-cozy {
+ --content-spacing: 1.75rem;
+ --paragraph-spacing: 1.25rem;
+ --heading-spacing: 2.5rem;
+ --line-height: 1.7;
+}
+
+/* Compactness: Spacious */
+.compactness-spacious {
+ --content-spacing: 2.25rem;
+ --paragraph-spacing: 1.5rem;
+ --heading-spacing: 3rem;
+ --line-height: 1.85;
+}
+
+/* Layout: Minimal */
+.layout-minimal .site-header {
+ justify-content: center;
+}
+.layout-minimal .site-nav {
+ display: none;
+}
+.layout-minimal .site-footer {
+ border-top: none;
+ opacity: 0.7;
+}
+.layout-minimal .profile {
+ margin-bottom: 3.5rem;
+}
+
+/* Layout: Magazine */
+.layout-magazine .posts-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 1.25rem;
+}
+.layout-magazine .post-card {
+ padding: 1.5rem;
+ border: 1px solid var(--border);
+ background: var(--bg);
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+.layout-magazine .post-card:hover {
+ border-color: var(--accent);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
+}
+.layout-magazine .post-card-title {
+ font-size: 1.0625rem;
+}
+.layout-magazine .post-card-description {
+ font-size: 0.875rem;
+ -webkit-line-clamp: 2;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+html {
+ font-size: 16px;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ font-family: var(--font-body);
+ color: var(--text);
+ background: var(--bg);
+ line-height: 1.6;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+ transition: color 0.15s;
+}
+
+a:hover {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+/* Header */
+.site-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ max-width: var(--content-width);
+ margin: 0 auto;
+ padding: 2rem var(--spacing);
+}
+
+.site-name {
+ font-weight: 600;
+ font-size: 1rem;
+ color: var(--text);
+ letter-spacing: -0.01em;
+}
+
+.site-name:hover {
+ text-decoration: none;
+ color: var(--accent);
+}
+
+.site-nav {
+ display: flex;
+ gap: 1.5rem;
+}
+
+.site-nav a {
+ color: var(--text-muted);
+ font-size: 0.9375rem;
+ font-weight: 450;
+}
+
+.site-nav a:hover {
+ color: var(--text);
+ text-decoration: none;
+}
+
+/* Main */
+main {
+ max-width: var(--content-width);
+ margin: 0 auto;
+ padding: 0 var(--spacing) 3rem;
+}
+
+/* Footer */
+.site-footer {
+ max-width: var(--content-width);
+ margin: 0 auto;
+ padding: 2.5rem var(--spacing);
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 0.8125rem;
+ border-top: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.powered-by {
+ color: var(--text-muted);
+ text-decoration: none;
+ font-size: 0.75rem;
+ opacity: 0.7;
+ transition: opacity 0.2s;
+}
+
+.powered-by:hover {
+ opacity: 1;
+}
+
+/* Profile */
+.profile {
+ text-align: center;
+ margin-bottom: 3rem;
+}
+
+.profile-avatar {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ margin-bottom: 1rem;
+}
+
+.profile-name {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin-bottom: 0.25rem;
+ letter-spacing: -0.01em;
+}
+
+.profile-bio {
+ color: var(--text-muted);
+ max-width: 380px;
+ margin: 0 auto;
+ font-size: 0.9375rem;
+ line-height: 1.6;
+}
+
+/* Post List */
+.posts-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--content-spacing);
+}
+
+.post-card a {
+ display: block;
+ color: inherit;
+}
+
+.post-card a:hover {
+ text-decoration: none;
+}
+
+.post-card-date {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.post-card-title {
+ margin: 0.375rem 0 0;
+ font-size: 1.1875rem;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ line-height: 1.35;
+}
+
+.post-card a:hover .post-card-title {
+ color: var(--accent);
+}
+
+.post-card-description {
+ margin: 0.5rem 0 0;
+ color: var(--text-muted);
+ font-size: 0.9375rem;
+ line-height: 1.55;
+}
+
+.no-posts {
+ color: var(--text-muted);
+ text-align: center;
+ padding: 3rem 0;
+}
+
+/* Pagination */
+.pagination {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 3rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid var(--border);
+ font-size: 0.9375rem;
+}
+
+/* Post Header */
+.post-header {
+ margin-bottom: 2.5rem;
+}
+
+.post-date {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.post-title {
+ margin: 0.5rem 0 0;
+ font-size: 2.25rem;
+ font-weight: 700;
+ line-height: 1.2;
+ letter-spacing: -0.025em;
+}
+
+.post-description {
+ font-size: 1.125rem;
+ color: var(--text-muted);
+ margin: 0.75rem 0 0;
+ line-height: 1.5;
+}
+
+.post-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 1.25rem;
+}
+
+.tag {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ background: var(--bg-secondary);
+ padding: 0.25rem 0.625rem;
+ border-radius: 0.25rem;
+}
+
+.post-cover {
+ margin: 1.75rem 0 0;
+}
+
+.post-cover img {
+ width: 100%;
+ height: auto;
+ border-radius: 0.5rem;
+ aspect-ratio: 16 / 9;
+ object-fit: cover;
+}
+
+/* Prose */
+.prose {
+ line-height: var(--line-height);
+ font-size: 1.0625rem;
+}
+
+.prose > *:first-child {
+ margin-top: 0;
+}
+
+.prose h2 {
+ margin-top: var(--heading-spacing);
+ margin-bottom: var(--paragraph-spacing);
+ font-size: 1.5rem;
+ font-weight: 650;
+ letter-spacing: -0.02em;
+ line-height: 1.3;
+}
+
+.prose h3 {
+ margin-top: calc(var(--heading-spacing) * 0.8);
+ margin-bottom: calc(var(--paragraph-spacing) * 0.8);
+ font-size: 1.25rem;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ line-height: 1.35;
+}
+
+.prose h4 {
+ margin-top: calc(var(--heading-spacing) * 0.65);
+ margin-bottom: calc(var(--paragraph-spacing) * 0.65);
+ font-size: 1.0625rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.prose p {
+ margin: var(--paragraph-spacing) 0;
+}
+
+.prose ul, .prose ol {
+ margin: var(--paragraph-spacing) 0;
+ padding-left: 1.375rem;
+}
+
+.prose li {
+ margin: calc(var(--paragraph-spacing) * 0.4) 0;
+ padding-left: 0.25rem;
+}
+
+.prose li::marker {
+ color: var(--text-muted);
+}
+
+.prose blockquote {
+ margin: var(--content-spacing) 0;
+ padding: 0 0 0 1.25rem;
+ border-left: 2px solid var(--border);
+ color: var(--text-muted);
+}
+
+.prose blockquote p {
+ margin: 0;
+}
+
+.prose pre {
+ margin: var(--content-spacing) 0;
+ padding: 1.125rem 1.25rem;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ overflow-x: auto;
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ line-height: 1.6;
+}
+
+.prose code {
+ font-family: var(--font-mono);
+ font-size: 0.875em;
+ background: var(--bg-secondary);
+ padding: 0.175rem 0.375rem;
+ border-radius: 0.25rem;
+ font-variant-ligatures: none;
+}
+
+.prose pre code {
+ background: none;
+ padding: 0;
+ font-size: inherit;
+}
+
+.prose img {
+ max-width: 100%;
+ height: auto;
+ border-radius: 0.375rem;
+ margin: var(--content-spacing) 0;
+}
+
+.prose a {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
+}
+
+.prose a:hover {
+ text-decoration-color: var(--accent);
+}
+
+.prose hr {
+ margin: var(--heading-spacing) 0;
+ border: none;
+ border-top: 1px solid var(--border);
+}
+
+.prose strong {
+ font-weight: 600;
+}
+
+.prose table {
+ width: 100%;
+ margin: var(--content-spacing) 0;
+ border-collapse: collapse;
+ font-size: 0.9375rem;
+}
+
+.prose th, .prose td {
+ padding: 0.625rem 0.75rem;
+ border: 1px solid var(--border);
+ text-align: left;
+}
+
+.prose th {
+ background: var(--bg-secondary);
+ font-weight: 600;
+}
+
+/* Reactions & Comments */
+.reactions, .comments {
+ margin-top: 3.5rem;
+ padding-top: 2rem;
+ border-top: 1px solid var(--border);
+}
+
+.comments-title {
+ margin: 0 0 1.25rem;
+ font-size: 1.125rem;
+ font-weight: 600;
+}
+
+.reaction-buttons {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.reaction-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.5rem 0.875rem;
+ border: 1px solid var(--border);
+ border-radius: 2rem;
+ background: var(--bg);
+ cursor: pointer;
+ font-size: 1rem;
+ transition: all 0.15s;
+}
+
+.reaction-btn:hover {
+ background: var(--bg-secondary);
+ border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
+}
+
+.reaction-btn.active {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: white;
+}
+
+.reaction-btn .count {
+ font-size: 0.8125rem;
+ font-weight: 500;
+}
+
+/* Search */
+.search-trigger {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.375rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ background: var(--bg);
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 0.875rem;
+ transition: border-color 0.15s;
+}
+
+.search-trigger:hover {
+ border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
+}
+
+.search-trigger kbd {
+ padding: 0.125rem 0.375rem;
+ background: var(--bg-secondary);
+ border-radius: 0.25rem;
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+}
+
+.search-modal {
+ display: none;
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+}
+
+.search-modal.active {
+ display: block;
+}
+
+.search-modal-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.4);
+ backdrop-filter: blur(2px);
+}
+
+.search-modal-content {
+ position: absolute;
+ top: 15%;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 90%;
+ max-width: 520px;
+ background: var(--bg);
+ border-radius: 0.5rem;
+ box-shadow: 0 20px 40px -8px rgba(0, 0, 0, 0.2);
+ overflow: hidden;
+}
+
+#search-input {
+ width: 100%;
+ padding: 1rem 1.25rem;
+ border: none;
+ font-size: 1rem;
+ outline: none;
+ background: var(--bg);
+ color: var(--text);
+}
+
+#search-input::placeholder {
+ color: var(--text-muted);
+}
+
+.search-results {
+ max-height: 320px;
+ overflow-y: auto;
+ border-top: 1px solid var(--border);
+}
+
+.search-result {
+ border-bottom: 1px solid var(--border);
+}
+
+.search-result:last-child {
+ border-bottom: none;
+}
+
+.search-result a {
+ display: block;
+ padding: 0.875rem 1.25rem;
+ color: inherit;
+}
+
+.search-result a:hover {
+ text-decoration: none;
+}
+
+.search-result:hover, .search-result.focused {
+ background: var(--bg-secondary);
+}
+
+.search-result-title {
+ font-weight: 500;
+ font-size: 0.9375rem;
+}
+
+.search-result-snippet {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ margin-top: 0.25rem;
+ line-height: 1.5;
+}
+
+.search-result mark {
+ background: color-mix(in srgb, var(--accent) 25%, transparent);
+ color: inherit;
+ border-radius: 0.125rem;
+ padding: 0 0.125rem;
+}
+
+.search-hint {
+ padding: 0.625rem 1.25rem;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ text-align: center;
+ border-top: 1px solid var(--border);
+}
+
+.search-hint kbd {
+ padding: 0.125rem 0.375rem;
+ background: var(--bg-secondary);
+ border-radius: 0.25rem;
+ font-family: var(--font-mono);
+}
+
+.search-no-results {
+ padding: 1.5rem 1.25rem;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 0.9375rem;
+}
+
+/* Comment Form */
+.comment-form textarea {
+ width: 100%;
+ padding: 0.875rem;
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ font-family: inherit;
+ font-size: 0.9375rem;
+ resize: vertical;
+ min-height: 100px;
+ background: var(--bg);
+ color: var(--text);
+ transition: border-color 0.15s;
+}
+
+.comment-form textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.comment-form button {
+ margin-top: 0.75rem;
+ padding: 0.5rem 1rem;
+ background: var(--accent);
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: opacity 0.15s;
+}
+
+.comment-form button:hover {
+ opacity: 0.9;
+}
+
+/* Comments List */
+.comments-list {
+ margin-top: 1.5rem;
+}
+
+.comment {
+ padding: 1.25rem 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.comment:first-child {
+ padding-top: 0;
+}
+
+.comment:last-child {
+ border-bottom: none;
+}
+
+.comment-header {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ margin-bottom: 0.625rem;
+}
+
+.comment-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+}
+
+.comment-author {
+ font-weight: 500;
+ font-size: 0.9375rem;
+}
+
+.comment-date {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+}
+
+.comment-content {
+ font-size: 0.9375rem;
+ line-height: 1.6;
+}
+
+/* Auth Prompt */
+.auth-prompt {
+ padding: 1.25rem;
+ background: var(--bg-secondary);
+ border-radius: 0.375rem;
+ text-align: center;
+ font-size: 0.9375rem;
+ color: var(--text-muted);
+}
+
+.auth-prompt a {
+ font-weight: 500;
+}
+
+/* Mobile */
+@media (max-width: 640px) {
+ :root {
+ --spacing: 1.25rem;
+ }
+
+ .site-header {
+ padding: 1.5rem var(--spacing);
+ }
+
+ .post-title {
+ font-size: 1.75rem;
+ }
+
+ .prose {
+ font-size: 1rem;
+ }
+
+ .prose h2 {
+ font-size: 1.375rem;
+ }
+
+ .prose h3 {
+ font-size: 1.125rem;
+ }
+}
diff --git a/internal/build/assets/js/main.js b/internal/build/assets/js/main.js
new file mode 100644
index 0000000..3695c68
--- /dev/null
+++ b/internal/build/assets/js/main.js
@@ -0,0 +1,127 @@
+(function() {
+ 'use strict';
+
+ // Live reload when studio saves settings
+ const channel = new BroadcastChannel('writekit-studio');
+ channel.onmessage = function(event) {
+ if (event.data.type === 'settings-changed') {
+ location.reload();
+ }
+ };
+
+ document.addEventListener('DOMContentLoaded', initSearch);
+
+ function initSearch() {
+ const trigger = document.getElementById('search-trigger');
+ const modal = document.getElementById('search-modal');
+ const backdrop = modal?.querySelector('.search-modal-backdrop');
+ const input = document.getElementById('search-input');
+ const results = document.getElementById('search-results');
+
+ if (!trigger || !modal || !input || !results) return;
+
+ let debounceTimer;
+
+ function open() {
+ modal.classList.add('active');
+ document.body.style.overflow = 'hidden';
+ input.value = '';
+ results.innerHTML = '';
+ setTimeout(() => input.focus(), 10);
+ }
+
+ function close() {
+ modal.classList.remove('active');
+ document.body.style.overflow = '';
+ }
+
+ trigger.addEventListener('click', open);
+ backdrop.addEventListener('click', close);
+
+ document.addEventListener('keydown', function(e) {
+ if (e.key === '/' && !modal.classList.contains('active') &&
+ !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
+ e.preventDefault();
+ open();
+ }
+ if (e.key === 'Escape' && modal.classList.contains('active')) {
+ close();
+ }
+ });
+
+ input.addEventListener('input', function() {
+ const query = this.value.trim();
+ clearTimeout(debounceTimer);
+
+ if (query.length < 2) {
+ results.innerHTML = '';
+ return;
+ }
+
+ debounceTimer = setTimeout(() => search(query), 150);
+ });
+
+ input.addEventListener('keydown', function(e) {
+ const items = results.querySelectorAll('.search-result');
+ const focused = results.querySelector('.search-result.focused');
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (!focused && items.length) {
+ items[0].classList.add('focused');
+ } else if (focused?.nextElementSibling) {
+ focused.classList.remove('focused');
+ focused.nextElementSibling.classList.add('focused');
+ }
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (focused?.previousElementSibling) {
+ focused.classList.remove('focused');
+ focused.previousElementSibling.classList.add('focused');
+ }
+ } else if (e.key === 'Enter' && focused) {
+ e.preventDefault();
+ const link = focused.querySelector('a');
+ if (link) window.location.href = link.href;
+ }
+ });
+
+ async function search(query) {
+ try {
+ const res = await fetch('/api/reader/search?q=' + encodeURIComponent(query));
+ const data = await res.json();
+
+ if (!data || data.length === 0) {
+ results.innerHTML = 'No results found
';
+ return;
+ }
+
+ results.innerHTML = data.map(r => `
+
+ `).join('');
+ } catch (e) {
+ results.innerHTML = 'Search failed
';
+ }
+ }
+
+ function highlight(text, query) {
+ if (!text) return '';
+ const escaped = escapeHtml(text);
+ const tokens = query.split(/\s+/).filter(t => t.length > 0);
+ if (!tokens.length) return escaped;
+ const pattern = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
+ return escaped.replace(new RegExp(`(${pattern})`, 'gi'), '$1');
+ }
+
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+ }
+})();
diff --git a/internal/build/assets/js/post.js b/internal/build/assets/js/post.js
new file mode 100644
index 0000000..278358d
--- /dev/null
+++ b/internal/build/assets/js/post.js
@@ -0,0 +1,197 @@
+var WriteKit = (function() {
+ 'use strict';
+
+ let config = {};
+ let user = null;
+
+ async function init(opts) {
+ config = opts;
+
+ try {
+ const res = await fetch('/api/reader/me');
+ const data = await res.json();
+ if (data.logged_in) {
+ user = data.user;
+ }
+ } catch (e) {}
+
+ if (config.reactions) initReactions();
+ if (config.comments) initComments();
+ }
+
+ async function initReactions() {
+ const container = document.querySelector('.reactions-container');
+ if (!container) return;
+
+ const res = await fetch(`/api/reader/posts/${config.slug}/reactions`);
+ const data = await res.json();
+
+ const counts = data.counts || {};
+ const userReactions = data.user || [];
+
+ if (config.reactionMode === 'upvote') {
+ const emoji = config.reactionEmojis[0] || '👍';
+ const count = counts[emoji] || 0;
+ const active = userReactions.includes(emoji);
+
+ container.innerHTML = `
+
+ `;
+ } else {
+ container.innerHTML = config.reactionEmojis.map(emoji => {
+ const count = counts[emoji] || 0;
+ const active = userReactions.includes(emoji);
+ return `
+
+ `;
+ }).join('');
+ }
+
+ container.querySelectorAll('.reaction-btn').forEach(btn => {
+ btn.addEventListener('click', () => toggleReaction(btn));
+ });
+ }
+
+ async function toggleReaction(btn) {
+ if (config.requireAuth && !user) {
+ showAuthPrompt('reactions');
+ return;
+ }
+
+ const emoji = btn.dataset.emoji;
+
+ try {
+ const res = await fetch(`/api/reader/posts/${config.slug}/reactions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ emoji })
+ });
+
+ if (res.status === 401) {
+ showAuthPrompt('reactions');
+ return;
+ }
+
+ const data = await res.json();
+ const countEl = btn.querySelector('.count');
+ const current = parseInt(countEl.textContent) || 0;
+
+ if (data.added) {
+ btn.classList.add('active');
+ countEl.textContent = current + 1;
+ } else {
+ btn.classList.remove('active');
+ countEl.textContent = Math.max(0, current - 1);
+ }
+ } catch (e) {
+ console.error('Failed to toggle reaction', e);
+ }
+ }
+
+ async function initComments() {
+ const section = document.querySelector('.comments');
+ if (!section) return;
+
+ const list = section.querySelector('.comments-list');
+ const formContainer = section.querySelector('.comment-form-container');
+
+ const res = await fetch(`/api/reader/posts/${config.slug}/comments`);
+ const comments = await res.json();
+
+ if (comments && comments.length > 0) {
+ list.innerHTML = comments.map(renderComment).join('');
+ } else {
+ list.innerHTML = '';
+ }
+
+ if (user) {
+ formContainer.innerHTML = `
+
+ `;
+ formContainer.querySelector('form').addEventListener('submit', submitComment);
+ } else {
+ formContainer.innerHTML = `
+
+ `;
+ }
+ }
+
+ function renderComment(comment) {
+ const date = new Date(comment.created_at).toLocaleDateString('en-US', {
+ year: 'numeric', month: 'short', day: 'numeric'
+ });
+
+ return `
+
+ `;
+ }
+
+ async function submitComment(e) {
+ e.preventDefault();
+ const form = e.target;
+ const textarea = form.querySelector('textarea');
+ const content = textarea.value.trim();
+
+ if (!content) return;
+
+ const btn = form.querySelector('button');
+ btn.disabled = true;
+ btn.textContent = 'Posting...';
+
+ try {
+ const res = await fetch(`/api/reader/posts/${config.slug}/comments`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content })
+ });
+
+ if (res.status === 401) {
+ showAuthPrompt('comments');
+ return;
+ }
+
+ const comment = await res.json();
+ const list = document.querySelector('.comments-list');
+ const noComments = list.querySelector('.no-comments');
+ if (noComments) noComments.remove();
+
+ list.insertAdjacentHTML('beforeend', renderComment(comment));
+ textarea.value = '';
+ } catch (e) {
+ console.error('Failed to post comment', e);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Post Comment';
+ }
+ }
+
+ function showAuthPrompt(feature) {
+ alert(`Please sign in to use ${feature}`);
+ }
+
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ return { init };
+})();
diff --git a/internal/build/templates/base.html b/internal/build/templates/base.html
new file mode 100644
index 0000000..876a092
--- /dev/null
+++ b/internal/build/templates/base.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+ {{.Title}}
+
+
+
+ {{if .NoIndex}}{{end}}
+
+
+
+
+
+
+ {{if .OGImage}}{{end}}
+
+
+
+
+
+
+ {{if .OGImage}}{{end}}
+
+
+
+
+ {{if .FontURL}}{{end}}
+
+
+
+
+
+ {{if .StructuredData}}
+
+ {{end}}
+
+
+
+
+
+ {{template "content" .}}
+
+
+
+
+
+
+
+
+
+
Press ESC to close
+
+
+
+
+ {{block "scripts" .}}{{end}}
+
+
diff --git a/internal/build/templates/blog.html b/internal/build/templates/blog.html
new file mode 100644
index 0000000..5ea0235
--- /dev/null
+++ b/internal/build/templates/blog.html
@@ -0,0 +1,34 @@
+{{define "content"}}
+
+
+
+
+
+ {{if or .PrevPage .NextPage}}
+
+ {{end}}
+
+{{end}}
diff --git a/internal/build/templates/home.html b/internal/build/templates/home.html
new file mode 100644
index 0000000..f03008b
--- /dev/null
+++ b/internal/build/templates/home.html
@@ -0,0 +1,34 @@
+{{define "content"}}
+
+ {{with index .Settings "author_bio"}}
+
+ {{with index $.Settings "author_avatar"}}
+
+ {{end}}
+ {{.}}
+
+ {{end}}
+
+
+
+ {{if .HasMore}}
+
+ {{end}}
+
+{{end}}
diff --git a/internal/build/templates/post.html b/internal/build/templates/post.html
new file mode 100644
index 0000000..10f1791
--- /dev/null
+++ b/internal/build/templates/post.html
@@ -0,0 +1,57 @@
+{{define "content"}}
+
+
+
+
+ {{.ContentHTML}}
+
+
+ {{if .InteractionConfig.ReactionsEnabled}}
+
+ {{end}}
+
+ {{if .InteractionConfig.CommentsEnabled}}
+
+ {{end}}
+
+{{end}}
+
+{{define "scripts"}}
+{{if or .InteractionConfig.ReactionsEnabled .InteractionConfig.CommentsEnabled}}
+
+
+{{end}}
+{{end}}
diff --git a/internal/build/templates/templates.go b/internal/build/templates/templates.go
new file mode 100644
index 0000000..4e1183a
--- /dev/null
+++ b/internal/build/templates/templates.go
@@ -0,0 +1,139 @@
+package templates
+
+import (
+ "bytes"
+ "embed"
+ "encoding/json"
+ "html/template"
+ "time"
+)
+
+//go:embed *.html
+var templateFS embed.FS
+
+var funcMap = template.FuncMap{
+ "safeHTML": func(s string) template.HTML { return template.HTML(s) },
+ "json": func(v any) string { b, _ := json.Marshal(v); return string(b) },
+ "or": func(a, b any) any {
+ if a != nil && a != "" {
+ return a
+ }
+ return b
+ },
+}
+
+var fontURLs = map[string]string{
+ "system": "",
+ "inter": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
+ "georgia": "",
+ "merriweather": "https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&display=swap",
+ "source-serif": "https://fonts.googleapis.com/css2?family=Source+Serif+4:wght@400;600;700&display=swap",
+ "jetbrains-mono": "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap",
+}
+
+var fontFamilies = map[string]string{
+ "system": "system-ui, -apple-system, sans-serif",
+ "inter": "'Inter', system-ui, sans-serif",
+ "georgia": "Georgia, 'Times New Roman', serif",
+ "merriweather": "'Merriweather', Georgia, serif",
+ "source-serif": "'Source Serif 4', Georgia, serif",
+ "jetbrains-mono": "'JetBrains Mono', 'Fira Code', monospace",
+}
+
+func GetFontURL(fontKey string) string {
+ if url, ok := fontURLs[fontKey]; ok {
+ return url
+ }
+ return ""
+}
+
+func GetFontFamily(fontKey string) string {
+ if family, ok := fontFamilies[fontKey]; ok {
+ return family
+ }
+ return fontFamilies["system"]
+}
+
+var homeTemplate, blogTemplate, postTemplate *template.Template
+
+func init() {
+ homeTemplate = template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "base.html", "home.html"))
+ blogTemplate = template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "base.html", "blog.html"))
+ postTemplate = template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "base.html", "post.html"))
+}
+
+type PageData struct {
+ Title string
+ Description string
+ CanonicalURL string
+ OGType string
+ OGImage string
+ NoIndex bool
+ SiteName string
+ Year int
+ FontURL string
+ FontFamily string
+ StructuredData template.JS
+ Settings map[string]any
+ ShowBadge bool
+}
+
+type HomeData struct {
+ PageData
+ Posts []PostSummary
+ HasMore bool
+}
+
+type BlogData struct {
+ PageData
+ Posts []PostSummary
+ PrevPage string
+ NextPage string
+}
+
+type PostData struct {
+ PageData
+ Post PostDetail
+ ContentHTML template.HTML
+ InteractionConfig map[string]any
+}
+
+type PostSummary struct {
+ Slug string
+ Title string
+ Description string
+ Date time.Time
+}
+
+type PostDetail struct {
+ Slug string
+ Title string
+ Description string
+ CoverImage string
+ Date time.Time
+ Tags []string
+}
+
+func RenderHome(data HomeData) ([]byte, error) {
+ var buf bytes.Buffer
+ if err := homeTemplate.ExecuteTemplate(&buf, "base.html", data); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+func RenderBlog(data BlogData) ([]byte, error) {
+ var buf bytes.Buffer
+ if err := blogTemplate.ExecuteTemplate(&buf, "base.html", data); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+func RenderPost(data PostData) ([]byte, error) {
+ var buf bytes.Buffer
+ if err := postTemplate.ExecuteTemplate(&buf, "base.html", data); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
diff --git a/internal/cloudflare/analytics.go b/internal/cloudflare/analytics.go
new file mode 100644
index 0000000..4255ab8
--- /dev/null
+++ b/internal/cloudflare/analytics.go
@@ -0,0 +1,451 @@
+package cloudflare
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "sync"
+ "time"
+)
+
+type Client struct {
+ apiToken string
+ zoneID string
+ client *http.Client
+ cache *analyticsCache
+}
+
+type analyticsCache struct {
+ mu sync.RWMutex
+ data map[string]*cachedResult
+ ttl time.Duration
+}
+
+type cachedResult struct {
+ result *ZoneAnalytics
+ expiresAt time.Time
+}
+
+func NewClient() *Client {
+ return &Client{
+ apiToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
+ zoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
+ client: &http.Client{Timeout: 30 * time.Second},
+ cache: &analyticsCache{
+ data: make(map[string]*cachedResult),
+ ttl: 5 * time.Minute,
+ },
+ }
+}
+
+func (c *Client) IsConfigured() bool {
+ return c.apiToken != "" && c.zoneID != ""
+}
+
+type ZoneAnalytics struct {
+ TotalRequests int64 `json:"totalRequests"`
+ TotalPageViews int64 `json:"totalPageViews"`
+ UniqueVisitors int64 `json:"uniqueVisitors"`
+ TotalBandwidth int64 `json:"totalBandwidth"`
+ Daily []DailyStats `json:"daily"`
+ Browsers []NamedCount `json:"browsers"`
+ OS []NamedCount `json:"os"`
+ Devices []NamedCount `json:"devices"`
+ Countries []NamedCount `json:"countries"`
+ Paths []PathStats `json:"paths"`
+}
+
+type DailyStats struct {
+ Date string `json:"date"`
+ Requests int64 `json:"requests"`
+ PageViews int64 `json:"pageViews"`
+ Visitors int64 `json:"visitors"`
+ Bandwidth int64 `json:"bandwidth"`
+}
+
+type NamedCount struct {
+ Name string `json:"name"`
+ Count int64 `json:"count"`
+}
+
+type PathStats struct {
+ Path string `json:"path"`
+ Requests int64 `json:"requests"`
+}
+
+type graphqlRequest struct {
+ Query string `json:"query"`
+ Variables map[string]any `json:"variables,omitempty"`
+}
+
+type graphqlResponse struct {
+ Data json.RawMessage `json:"data"`
+ Errors []struct {
+ Message string `json:"message"`
+ } `json:"errors,omitempty"`
+}
+
+func (c *Client) GetAnalytics(ctx context.Context, days int, hostname string) (*ZoneAnalytics, error) {
+ if !c.IsConfigured() {
+ return nil, fmt.Errorf("cloudflare not configured")
+ }
+
+ cacheKey := fmt.Sprintf("%s:%d:%s", c.zoneID, days, hostname)
+ if cached := c.cache.get(cacheKey); cached != nil {
+ return cached, nil
+ }
+
+ since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
+ until := time.Now().Format("2006-01-02")
+
+ result := &ZoneAnalytics{}
+
+ dailyData, err := c.fetchDailyStats(ctx, since, until, hostname)
+ if err != nil {
+ return nil, err
+ }
+ result.Daily = dailyData
+
+ for _, d := range dailyData {
+ result.TotalRequests += d.Requests
+ result.TotalPageViews += d.PageViews
+ result.UniqueVisitors += d.Visitors
+ result.TotalBandwidth += d.Bandwidth
+ }
+
+ browsers, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "userAgentBrowser")
+ result.Browsers = browsers
+
+ osStats, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "userAgentOS")
+ result.OS = osStats
+
+ devices, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "deviceType")
+ result.Devices = devices
+
+ countries, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "clientCountryName")
+ result.Countries = countries
+
+ paths, _ := c.fetchPathStats(ctx, since, until, hostname)
+ result.Paths = paths
+
+ c.cache.set(cacheKey, result)
+
+ return result, nil
+}
+
+func (c *Client) fetchDailyStats(ctx context.Context, since, until, hostname string) ([]DailyStats, error) {
+ query := `
+ query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) {
+ viewer {
+ zones(filter: { zoneTag: $zoneTag }) {
+ httpRequests1dGroups(
+ filter: { date_geq: $since, date_leq: $until, clientRequestHTTPHost: $hostname }
+ orderBy: [date_ASC]
+ limit: 100
+ ) {
+ dimensions {
+ date
+ }
+ sum {
+ requests
+ pageViews
+ bytes
+ }
+ uniq {
+ uniques
+ }
+ }
+ }
+ }
+ }
+ `
+
+ vars := map[string]any{
+ "zoneTag": c.zoneID,
+ "since": since,
+ "until": until,
+ "hostname": hostname,
+ }
+
+ resp, err := c.doQuery(ctx, query, vars)
+ if err != nil {
+ return nil, err
+ }
+
+ var data struct {
+ Viewer struct {
+ Zones []struct {
+ HttpRequests1dGroups []struct {
+ Dimensions struct {
+ Date string `json:"date"`
+ } `json:"dimensions"`
+ Sum struct {
+ Requests int64 `json:"requests"`
+ PageViews int64 `json:"pageViews"`
+ Bytes int64 `json:"bytes"`
+ } `json:"sum"`
+ Uniq struct {
+ Uniques int64 `json:"uniques"`
+ } `json:"uniq"`
+ } `json:"httpRequests1dGroups"`
+ } `json:"zones"`
+ } `json:"viewer"`
+ }
+
+ if err := json.Unmarshal(resp, &data); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ var results []DailyStats
+ if len(data.Viewer.Zones) > 0 {
+ for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups {
+ results = append(results, DailyStats{
+ Date: g.Dimensions.Date,
+ Requests: g.Sum.Requests,
+ PageViews: g.Sum.PageViews,
+ Visitors: g.Uniq.Uniques,
+ Bandwidth: g.Sum.Bytes,
+ })
+ }
+ }
+
+ return results, nil
+}
+
+func (c *Client) fetchGroupedStats(ctx context.Context, since, until, hostname, hostField, groupBy string) ([]NamedCount, error) {
+ query := fmt.Sprintf(`
+ query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) {
+ viewer {
+ zones(filter: { zoneTag: $zoneTag }) {
+ httpRequests1dGroups(
+ filter: { date_geq: $since, date_leq: $until, %s: $hostname }
+ orderBy: [sum_requests_DESC]
+ limit: 10
+ ) {
+ dimensions {
+ %s
+ }
+ sum {
+ requests
+ }
+ }
+ }
+ }
+ }
+ `, hostField, groupBy)
+
+ vars := map[string]any{
+ "zoneTag": c.zoneID,
+ "since": since,
+ "until": until,
+ "hostname": hostname,
+ }
+
+ resp, err := c.doQuery(ctx, query, vars)
+ if err != nil {
+ return nil, err
+ }
+
+ var data struct {
+ Viewer struct {
+ Zones []struct {
+ HttpRequests1dGroups []struct {
+ Dimensions map[string]string `json:"dimensions"`
+ Sum struct {
+ Requests int64 `json:"requests"`
+ } `json:"sum"`
+ } `json:"httpRequests1dGroups"`
+ } `json:"zones"`
+ } `json:"viewer"`
+ }
+
+ if err := json.Unmarshal(resp, &data); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ var results []NamedCount
+ if len(data.Viewer.Zones) > 0 {
+ for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups {
+ name := g.Dimensions[groupBy]
+ if name == "" {
+ name = "Unknown"
+ }
+ results = append(results, NamedCount{
+ Name: name,
+ Count: g.Sum.Requests,
+ })
+ }
+ }
+
+ return results, nil
+}
+
+func (c *Client) fetchPathStats(ctx context.Context, since, until, hostname string) ([]PathStats, error) {
+ query := `
+ query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) {
+ viewer {
+ zones(filter: { zoneTag: $zoneTag }) {
+ httpRequests1dGroups(
+ filter: { date_geq: $since, date_leq: $until, clientRequestHTTPHost: $hostname }
+ orderBy: [sum_requests_DESC]
+ limit: 20
+ ) {
+ dimensions {
+ clientRequestPath
+ }
+ sum {
+ requests
+ }
+ }
+ }
+ }
+ }
+ `
+
+ vars := map[string]any{
+ "zoneTag": c.zoneID,
+ "since": since,
+ "until": until,
+ "hostname": hostname,
+ }
+
+ resp, err := c.doQuery(ctx, query, vars)
+ if err != nil {
+ return nil, err
+ }
+
+ var data struct {
+ Viewer struct {
+ Zones []struct {
+ HttpRequests1dGroups []struct {
+ Dimensions struct {
+ Path string `json:"clientRequestPath"`
+ } `json:"dimensions"`
+ Sum struct {
+ Requests int64 `json:"requests"`
+ } `json:"sum"`
+ } `json:"httpRequests1dGroups"`
+ } `json:"zones"`
+ } `json:"viewer"`
+ }
+
+ if err := json.Unmarshal(resp, &data); err != nil {
+ return nil, fmt.Errorf("parse response: %w", err)
+ }
+
+ var results []PathStats
+ if len(data.Viewer.Zones) > 0 {
+ for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups {
+ results = append(results, PathStats{
+ Path: g.Dimensions.Path,
+ Requests: g.Sum.Requests,
+ })
+ }
+ }
+
+ return results, nil
+}
+
+func (c *Client) doQuery(ctx context.Context, query string, vars map[string]any) (json.RawMessage, error) {
+ reqBody := graphqlRequest{
+ Query: query,
+ Variables: vars,
+ }
+
+ body, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", "https://api.cloudflare.com/client/v4/graphql", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.apiToken)
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("cloudflare API error: %s", string(respBody))
+ }
+
+ var gqlResp graphqlResponse
+ if err := json.Unmarshal(respBody, &gqlResp); err != nil {
+ return nil, err
+ }
+
+ if len(gqlResp.Errors) > 0 {
+ return nil, fmt.Errorf("graphql error: %s", gqlResp.Errors[0].Message)
+ }
+
+ return gqlResp.Data, nil
+}
+
+func (c *analyticsCache) get(key string) *ZoneAnalytics {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ if cached, ok := c.data[key]; ok && time.Now().Before(cached.expiresAt) {
+ return cached.result
+ }
+ return nil
+}
+
+func (c *analyticsCache) set(key string, result *ZoneAnalytics) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.data[key] = &cachedResult{
+ result: result,
+ expiresAt: time.Now().Add(c.ttl),
+ }
+}
+
+func (c *Client) PurgeURLs(ctx context.Context, urls []string) error {
+ if !c.IsConfigured() || len(urls) == 0 {
+ return nil
+ }
+
+ body, err := json.Marshal(map[string][]string{"files": urls})
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST",
+ fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/purge_cache", c.zoneID),
+ bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.apiToken)
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ respBody, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("cloudflare purge failed: %s", string(respBody))
+ }
+
+ return nil
+}
diff --git a/internal/config/tiers.go b/internal/config/tiers.go
new file mode 100644
index 0000000..cb96efa
--- /dev/null
+++ b/internal/config/tiers.go
@@ -0,0 +1,100 @@
+package config
+
+type Tier string
+
+const (
+ TierFree Tier = "free"
+ TierPro Tier = "pro"
+)
+
+type TierConfig struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ MonthlyPrice int `json:"monthly_price"`
+ AnnualPrice int `json:"annual_price"`
+ CustomDomain bool `json:"custom_domain"`
+ BadgeRequired bool `json:"badge_required"`
+ AnalyticsRetention int `json:"analytics_retention"`
+ APIRateLimit int `json:"api_rate_limit"`
+ MaxWebhooks int `json:"max_webhooks"`
+ WebhookDeliveries int `json:"webhook_deliveries"`
+ MaxPlugins int `json:"max_plugins"`
+ PluginExecutions int `json:"plugin_executions"`
+}
+
+var Tiers = map[Tier]TierConfig{
+ TierFree: {
+ Name: "Free",
+ Description: "For getting started",
+ MonthlyPrice: 0,
+ AnnualPrice: 0,
+ CustomDomain: false,
+ BadgeRequired: true,
+ AnalyticsRetention: 7,
+ APIRateLimit: 100,
+ MaxWebhooks: 3,
+ WebhookDeliveries: 100,
+ MaxPlugins: 3,
+ PluginExecutions: 1000,
+ },
+ TierPro: {
+ Name: "Pro",
+ Description: "For serious bloggers",
+ MonthlyPrice: 500,
+ AnnualPrice: 4900,
+ CustomDomain: true,
+ BadgeRequired: false,
+ AnalyticsRetention: 90,
+ APIRateLimit: 1000,
+ MaxWebhooks: 10,
+ WebhookDeliveries: 1000,
+ MaxPlugins: 10,
+ PluginExecutions: 10000,
+ },
+}
+
+func GetTier(premium bool) Tier {
+ if premium {
+ return TierPro
+ }
+ return TierFree
+}
+
+func GetConfig(tier Tier) TierConfig {
+ if cfg, ok := Tiers[tier]; ok {
+ return cfg
+ }
+ return Tiers[TierFree]
+}
+
+type TierInfo struct {
+ Tier Tier `json:"tier"`
+ Config TierConfig `json:"config"`
+}
+
+func GetTierInfo(premium bool) TierInfo {
+ tier := GetTier(premium)
+ return TierInfo{
+ Tier: tier,
+ Config: GetConfig(tier),
+ }
+}
+
+type Usage struct {
+ Webhooks int `json:"webhooks"`
+ Plugins int `json:"plugins"`
+}
+
+type AllTiersResponse struct {
+ CurrentTier Tier `json:"current_tier"`
+ Tiers map[Tier]TierConfig `json:"tiers"`
+ Usage Usage `json:"usage"`
+}
+
+func GetAllTiers(premium bool, usage Usage) AllTiersResponse {
+ return AllTiersResponse{
+ CurrentTier: GetTier(premium),
+ Tiers: Tiers,
+ Usage: usage,
+ }
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 0000000..83809aa
--- /dev/null
+++ b/internal/db/db.go
@@ -0,0 +1,81 @@
+package db
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+const defaultDSN = "postgres://writekit:writekit@localhost:5432/writekit?sslmode=disable"
+
+type DB struct {
+ pool *pgxpool.Pool
+}
+
+func Connect(migrationsDir string) (*DB, error) {
+ dsn := os.Getenv("DATABASE_URL")
+ if dsn == "" {
+ dsn = defaultDSN
+ }
+
+ pool, err := pgxpool.New(context.Background(), dsn)
+ if err != nil {
+ return nil, fmt.Errorf("connect: %w", err)
+ }
+
+ if err := pool.Ping(context.Background()); err != nil {
+ pool.Close()
+ return nil, fmt.Errorf("ping: %w", err)
+ }
+
+ db := &DB{pool: pool}
+
+ if err := db.RunMigrations(migrationsDir); err != nil {
+ pool.Close()
+ return nil, fmt.Errorf("migrations: %w", err)
+ }
+
+ return db, nil
+}
+
+func (db *DB) RunMigrations(dir string) error {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return fmt.Errorf("read migrations dir: %w", err)
+ }
+
+ var files []string
+ for _, entry := range entries {
+ if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
+ continue
+ }
+ files = append(files, entry.Name())
+ }
+ sort.Strings(files)
+
+ for _, name := range files {
+ content, err := os.ReadFile(filepath.Join(dir, name))
+ if err != nil {
+ return fmt.Errorf("read %s: %w", name, err)
+ }
+
+ if _, err := db.pool.Exec(context.Background(), string(content)); err != nil {
+ return fmt.Errorf("run %s: %w", name, err)
+ }
+ }
+
+ return nil
+}
+
+func (db *DB) Close() {
+ db.pool.Close()
+}
+
+func (db *DB) Pool() *pgxpool.Pool {
+ return db.pool
+}
+
diff --git a/internal/db/demos.go b/internal/db/demos.go
new file mode 100644
index 0000000..7ecbdf0
--- /dev/null
+++ b/internal/db/demos.go
@@ -0,0 +1,109 @@
+package db
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func getDemoDuration() time.Duration {
+ if mins := os.Getenv("DEMO_DURATION_MINUTES"); mins != "" {
+ if m, err := strconv.Atoi(mins); err == nil && m > 0 {
+ return time.Duration(m) * time.Minute
+ }
+ }
+ if os.Getenv("ENV") != "prod" {
+ return 100 * 365 * 24 * time.Hour // infinite in local/dev
+ }
+ return 15 * time.Minute
+}
+
+func (db *DB) GetDemoBySubdomain(ctx context.Context, subdomain string) (*Demo, error) {
+ var d Demo
+ err := db.pool.QueryRow(ctx,
+ `SELECT id, subdomain, expires_at FROM demos WHERE subdomain = $1 AND expires_at > NOW()`,
+ subdomain).Scan(&d.ID, &d.Subdomain, &d.ExpiresAt)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return &d, err
+}
+
+func (db *DB) GetDemoByID(ctx context.Context, id string) (*Demo, error) {
+ var d Demo
+ err := db.pool.QueryRow(ctx,
+ `SELECT id, subdomain, expires_at FROM demos WHERE id = $1 AND expires_at > NOW()`,
+ id).Scan(&d.ID, &d.Subdomain, &d.ExpiresAt)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return &d, err
+}
+
+func (db *DB) CreateDemo(ctx context.Context) (*Demo, error) {
+ subdomain := "demo-" + randomHex(4)
+ expiresAt := time.Now().Add(getDemoDuration())
+
+ var d Demo
+ err := db.pool.QueryRow(ctx,
+ `INSERT INTO demos (subdomain, expires_at) VALUES ($1, $2) RETURNING id, subdomain, expires_at`,
+ subdomain, expiresAt).Scan(&d.ID, &d.Subdomain, &d.ExpiresAt)
+ if err != nil {
+ return nil, err
+ }
+ return &d, nil
+}
+
+type ExpiredDemo struct {
+ ID string
+ Subdomain string
+}
+
+func (db *DB) CleanupExpiredDemos(ctx context.Context) ([]ExpiredDemo, error) {
+ rows, err := db.pool.Query(ctx,
+ `DELETE FROM demos WHERE expires_at < NOW() RETURNING id, subdomain`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var demos []ExpiredDemo
+ for rows.Next() {
+ var d ExpiredDemo
+ if err := rows.Scan(&d.ID, &d.Subdomain); err != nil {
+ return nil, err
+ }
+ demos = append(demos, d)
+ }
+ return demos, rows.Err()
+}
+
+func (db *DB) ListActiveDemos(ctx context.Context) ([]Demo, error) {
+ rows, err := db.pool.Query(ctx,
+ `SELECT id, subdomain, expires_at FROM demos WHERE expires_at > NOW()`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var demos []Demo
+ for rows.Next() {
+ var d Demo
+ if err := rows.Scan(&d.ID, &d.Subdomain, &d.ExpiresAt); err != nil {
+ return nil, err
+ }
+ demos = append(demos, d)
+ }
+ return demos, rows.Err()
+}
+
+func randomHex(n int) string {
+ b := make([]byte, n)
+ rand.Read(b)
+ return hex.EncodeToString(b)
+}
diff --git a/internal/db/migrations/001_initial.sql b/internal/db/migrations/001_initial.sql
new file mode 100644
index 0000000..94f3bc3
--- /dev/null
+++ b/internal/db/migrations/001_initial.sql
@@ -0,0 +1,185 @@
+-- Users (provider-agnostic)
+CREATE TABLE IF NOT EXISTS users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ email VARCHAR(255) UNIQUE NOT NULL,
+ name VARCHAR(255),
+ avatar_url TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- OAuth identities
+CREATE TABLE IF NOT EXISTS user_identities (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider VARCHAR(50) NOT NULL,
+ provider_id VARCHAR(255) NOT NULL,
+ provider_email VARCHAR(255),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ UNIQUE(provider, provider_id)
+);
+
+-- Sessions
+CREATE TABLE IF NOT EXISTS sessions (
+ token VARCHAR(64) PRIMARY KEY,
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL
+);
+
+-- Tenants (blog instances)
+CREATE TABLE IF NOT EXISTS tenants (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ subdomain VARCHAR(63) UNIQUE NOT NULL,
+ custom_domain VARCHAR(255),
+ premium BOOLEAN DEFAULT FALSE,
+ members_enabled BOOLEAN DEFAULT FALSE,
+ donations_enabled BOOLEAN DEFAULT FALSE,
+ auth_google_enabled BOOLEAN DEFAULT TRUE,
+ auth_github_enabled BOOLEAN DEFAULT TRUE,
+ auth_discord_enabled BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Demos (temporary blogs)
+CREATE TABLE IF NOT EXISTS demos (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ subdomain VARCHAR(63) UNIQUE NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL
+);
+
+-- Reserved subdomains
+CREATE TABLE IF NOT EXISTS reserved_subdomains (
+ subdomain VARCHAR(63) PRIMARY KEY,
+ reason VARCHAR(255)
+);
+
+-- Membership tiers
+CREATE TABLE IF NOT EXISTS membership_tiers (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ name VARCHAR(100) NOT NULL,
+ price_cents INTEGER NOT NULL,
+ description TEXT,
+ benefits TEXT[],
+ lemon_variant_id VARCHAR(64),
+ active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Subscriptions
+CREATE TABLE IF NOT EXISTS subscriptions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ tier_id UUID REFERENCES membership_tiers(id) ON DELETE SET NULL,
+ tier_name VARCHAR(100) NOT NULL,
+ status VARCHAR(20) NOT NULL,
+ lemon_subscription_id VARCHAR(64) UNIQUE,
+ lemon_customer_id VARCHAR(64),
+ amount_cents INTEGER NOT NULL,
+ current_period_start TIMESTAMP WITH TIME ZONE,
+ current_period_end TIMESTAMP WITH TIME ZONE,
+ cancelled_at TIMESTAMP WITH TIME ZONE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Donations
+CREATE TABLE IF NOT EXISTS donations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ donor_email VARCHAR(255),
+ donor_name VARCHAR(255),
+ amount_cents INTEGER NOT NULL,
+ lemon_order_id VARCHAR(64) UNIQUE,
+ message TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Earnings ledger
+CREATE TABLE IF NOT EXISTS earnings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ source_type VARCHAR(20) NOT NULL,
+ source_id UUID NOT NULL,
+ description TEXT,
+ gross_cents INTEGER NOT NULL,
+ platform_fee_cents INTEGER NOT NULL,
+ processor_fee_cents INTEGER NOT NULL,
+ net_cents INTEGER NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Balances
+CREATE TABLE IF NOT EXISTS balances (
+ tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
+ available_cents INTEGER DEFAULT 0,
+ lifetime_earnings_cents INTEGER DEFAULT 0,
+ lifetime_paid_cents INTEGER DEFAULT 0,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Payouts
+CREATE TABLE IF NOT EXISTS payouts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ amount_cents INTEGER NOT NULL,
+ currency VARCHAR(3) DEFAULT 'USD',
+ wise_transfer_id VARCHAR(64),
+ wise_quote_id VARCHAR(64),
+ status VARCHAR(20) NOT NULL,
+ failure_reason TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ completed_at TIMESTAMP WITH TIME ZONE
+);
+
+-- Payout settings
+CREATE TABLE IF NOT EXISTS payout_settings (
+ tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
+ wise_recipient_id VARCHAR(64),
+ account_holder_name VARCHAR(255),
+ currency VARCHAR(3) DEFAULT 'USD',
+ payout_email VARCHAR(255),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Indexes
+CREATE INDEX IF NOT EXISTS idx_identities_lookup ON user_identities(provider, provider_id);
+CREATE INDEX IF NOT EXISTS idx_identities_user ON user_identities(user_id);
+CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
+CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
+CREATE INDEX IF NOT EXISTS idx_tenants_owner ON tenants(owner_id);
+CREATE INDEX IF NOT EXISTS idx_tenants_subdomain ON tenants(subdomain);
+CREATE INDEX IF NOT EXISTS idx_demos_expires ON demos(expires_at);
+CREATE INDEX IF NOT EXISTS idx_demos_subdomain ON demos(subdomain);
+CREATE INDEX IF NOT EXISTS idx_tiers_tenant ON membership_tiers(tenant_id) WHERE active = TRUE;
+CREATE INDEX IF NOT EXISTS idx_subscriptions_tenant ON subscriptions(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
+CREATE INDEX IF NOT EXISTS idx_subscriptions_lemon ON subscriptions(lemon_subscription_id);
+CREATE INDEX IF NOT EXISTS idx_donations_tenant ON donations(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_earnings_tenant ON earnings(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_payouts_tenant ON payouts(tenant_id);
+
+-- Reserved subdomains
+INSERT INTO reserved_subdomains (subdomain, reason) VALUES
+ ('www', 'system'),
+ ('api', 'system'),
+ ('app', 'system'),
+ ('admin', 'system'),
+ ('staging', 'system'),
+ ('demo', 'system'),
+ ('test', 'system'),
+ ('mail', 'system'),
+ ('smtp', 'system'),
+ ('ftp', 'system'),
+ ('ssh', 'system'),
+ ('traefik', 'system'),
+ ('ops', 'system'),
+ ('source', 'system'),
+ ('ci', 'system')
+ON CONFLICT (subdomain) DO NOTHING;
diff --git a/internal/db/models.go b/internal/db/models.go
new file mode 100644
index 0000000..2dc522b
--- /dev/null
+++ b/internal/db/models.go
@@ -0,0 +1,34 @@
+package db
+
+import "time"
+
+type User struct {
+ ID string
+ Email string
+ Name string
+ AvatarURL string
+ CreatedAt time.Time
+}
+
+type Tenant struct {
+ ID string
+ OwnerID string
+ Subdomain string
+ CustomDomain string
+ Premium bool
+ MembersEnabled bool
+ DonationsEnabled bool
+ CreatedAt time.Time
+}
+
+type Session struct {
+ Token string
+ UserID string
+ ExpiresAt time.Time
+}
+
+type Demo struct {
+ ID string
+ Subdomain string
+ ExpiresAt time.Time
+}
diff --git a/internal/db/sessions.go b/internal/db/sessions.go
new file mode 100644
index 0000000..92e6987
--- /dev/null
+++ b/internal/db/sessions.go
@@ -0,0 +1,45 @@
+package db
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func (db *DB) CreateSession(ctx context.Context, userID string) (*Session, error) {
+ token := make([]byte, 32)
+ if _, err := rand.Read(token); err != nil {
+ return nil, err
+ }
+
+ s := &Session{
+ Token: hex.EncodeToString(token),
+ UserID: userID,
+ ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
+ }
+
+ _, err := db.pool.Exec(ctx,
+ `INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, $3)`,
+ s.Token, s.UserID, s.ExpiresAt)
+ return s, err
+}
+
+func (db *DB) ValidateSession(ctx context.Context, token string) (*Session, error) {
+ var s Session
+ err := db.pool.QueryRow(ctx,
+ `SELECT token, user_id, expires_at FROM sessions
+ WHERE token = $1 AND expires_at > NOW()`,
+ token).Scan(&s.Token, &s.UserID, &s.ExpiresAt)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return &s, err
+}
+
+func (db *DB) DeleteSession(ctx context.Context, token string) error {
+ _, err := db.pool.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, token)
+ return err
+}
diff --git a/internal/db/tenants.go b/internal/db/tenants.go
new file mode 100644
index 0000000..6215975
--- /dev/null
+++ b/internal/db/tenants.go
@@ -0,0 +1,147 @@
+package db
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func (db *DB) GetTenantBySubdomain(ctx context.Context, subdomain string) (*Tenant, error) {
+ var t Tenant
+ var ownerID, customDomain *string
+ err := db.pool.QueryRow(ctx,
+ `SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
+ FROM tenants WHERE subdomain = $1`,
+ subdomain).Scan(&t.ID, &ownerID, &t.Subdomain, &customDomain, &t.Premium,
+ &t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ if ownerID != nil {
+ t.OwnerID = *ownerID
+ }
+ if customDomain != nil {
+ t.CustomDomain = *customDomain
+ }
+ return &t, err
+}
+
+func (db *DB) GetTenantByOwner(ctx context.Context, ownerID string) (*Tenant, error) {
+ var t Tenant
+ var owner, customDomain *string
+ err := db.pool.QueryRow(ctx,
+ `SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
+ FROM tenants WHERE owner_id = $1`,
+ ownerID).Scan(&t.ID, &owner, &t.Subdomain, &customDomain, &t.Premium,
+ &t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ if owner != nil {
+ t.OwnerID = *owner
+ }
+ if customDomain != nil {
+ t.CustomDomain = *customDomain
+ }
+ return &t, err
+}
+
+func (db *DB) CreateTenant(ctx context.Context, ownerID, subdomain string) (*Tenant, error) {
+ var t Tenant
+ err := db.pool.QueryRow(ctx,
+ `INSERT INTO tenants (owner_id, subdomain)
+ VALUES ($1, $2)
+ RETURNING id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at`,
+ ownerID, subdomain).Scan(&t.ID, &t.OwnerID, &t.Subdomain, &t.CustomDomain, &t.Premium,
+ &t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
+ return &t, err
+}
+
+func (db *DB) GetTenantByID(ctx context.Context, id string) (*Tenant, error) {
+ var t Tenant
+ var ownerID, customDomain *string
+ err := db.pool.QueryRow(ctx,
+ `SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
+ FROM tenants WHERE id = $1`,
+ id).Scan(&t.ID, &ownerID, &t.Subdomain, &customDomain, &t.Premium,
+ &t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ if ownerID != nil {
+ t.OwnerID = *ownerID
+ }
+ if customDomain != nil {
+ t.CustomDomain = *customDomain
+ }
+ return &t, err
+}
+
+func (db *DB) IsUserTenantOwner(ctx context.Context, userID, tenantID string) (bool, error) {
+ var exists bool
+ err := db.pool.QueryRow(ctx,
+ `SELECT EXISTS(SELECT 1 FROM tenants WHERE id = $1 AND owner_id = $2)`,
+ tenantID, userID).Scan(&exists)
+ return exists, err
+}
+
+func (db *DB) IsSubdomainAvailable(ctx context.Context, subdomain string) (bool, error) {
+ var exists bool
+
+ err := db.pool.QueryRow(ctx,
+ `SELECT EXISTS(SELECT 1 FROM reserved_subdomains WHERE subdomain = $1)`,
+ subdomain).Scan(&exists)
+ if err != nil {
+ return false, err
+ }
+ if exists {
+ return false, nil
+ }
+
+ err = db.pool.QueryRow(ctx,
+ `SELECT EXISTS(SELECT 1 FROM demos WHERE subdomain = $1 AND expires_at > NOW())`,
+ subdomain).Scan(&exists)
+ if err != nil {
+ return false, err
+ }
+ if exists {
+ return false, nil
+ }
+
+ err = db.pool.QueryRow(ctx,
+ `SELECT EXISTS(SELECT 1 FROM tenants WHERE subdomain = $1)`,
+ subdomain).Scan(&exists)
+ if err != nil {
+ return false, err
+ }
+
+ return !exists, nil
+}
+
+func (db *DB) ListTenants(ctx context.Context) ([]Tenant, error) {
+ rows, err := db.pool.Query(ctx,
+ `SELECT id, owner_id, subdomain, custom_domain, premium, members_enabled, donations_enabled, created_at
+ FROM tenants`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tenants []Tenant
+ for rows.Next() {
+ var t Tenant
+ var ownerID, customDomain *string
+ if err := rows.Scan(&t.ID, &ownerID, &t.Subdomain, &customDomain, &t.Premium,
+ &t.MembersEnabled, &t.DonationsEnabled, &t.CreatedAt); err != nil {
+ return nil, err
+ }
+ if ownerID != nil {
+ t.OwnerID = *ownerID
+ }
+ if customDomain != nil {
+ t.CustomDomain = *customDomain
+ }
+ tenants = append(tenants, t)
+ }
+ return tenants, rows.Err()
+}
diff --git a/internal/db/users.go b/internal/db/users.go
new file mode 100644
index 0000000..d9ed170
--- /dev/null
+++ b/internal/db/users.go
@@ -0,0 +1,62 @@
+package db
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func (db *DB) GetUserByID(ctx context.Context, id string) (*User, error) {
+ var u User
+ err := db.pool.QueryRow(ctx,
+ `SELECT id, email, name, avatar_url, created_at FROM users WHERE id = $1`,
+ id).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return &u, err
+}
+
+func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
+ var u User
+ err := db.pool.QueryRow(ctx,
+ `SELECT id, email, name, avatar_url, created_at FROM users WHERE email = $1`,
+ email).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return &u, err
+}
+
+func (db *DB) GetUserByIdentity(ctx context.Context, provider, providerID string) (*User, error) {
+ var u User
+ err := db.pool.QueryRow(ctx,
+ `SELECT u.id, u.email, u.name, u.avatar_url, u.created_at
+ FROM users u
+ JOIN user_identities i ON i.user_id = u.id
+ WHERE i.provider = $1 AND i.provider_id = $2`,
+ provider, providerID).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return &u, err
+}
+
+func (db *DB) CreateUser(ctx context.Context, email, name, avatarURL string) (*User, error) {
+ var u User
+ err := db.pool.QueryRow(ctx,
+ `INSERT INTO users (email, name, avatar_url)
+ VALUES ($1, $2, $3)
+ RETURNING id, email, name, avatar_url, created_at`,
+ email, name, avatarURL).Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.CreatedAt)
+ return &u, err
+}
+
+func (db *DB) AddUserIdentity(ctx context.Context, userID, provider, providerID, providerEmail string) error {
+ _, err := db.pool.Exec(ctx,
+ `INSERT INTO user_identities (user_id, provider, provider_id, provider_email)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (provider, provider_id) DO NOTHING`,
+ userID, provider, providerID, providerEmail)
+ return err
+}
diff --git a/internal/imaginary/client.go b/internal/imaginary/client.go
new file mode 100644
index 0000000..394af08
--- /dev/null
+++ b/internal/imaginary/client.go
@@ -0,0 +1,77 @@
+package imaginary
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+)
+
+type Client struct {
+ baseURL string
+ http *http.Client
+}
+
+func New(baseURL string) *Client {
+ return &Client{
+ baseURL: baseURL,
+ http: &http.Client{},
+ }
+}
+
+type ProcessOptions struct {
+ Width int
+ Height int
+ Quality int
+ Type string
+}
+
+func (c *Client) Process(src io.Reader, filename string, opts ProcessOptions) ([]byte, error) {
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+
+ part, err := writer.CreateFormFile("file", filename)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := io.Copy(part, src); err != nil {
+ return nil, err
+ }
+ writer.Close()
+
+ if opts.Width == 0 {
+ opts.Width = 2000
+ }
+ if opts.Height == 0 {
+ opts.Height = 2000
+ }
+ if opts.Quality == 0 {
+ opts.Quality = 80
+ }
+ if opts.Type == "" {
+ opts.Type = "webp"
+ }
+
+ url := fmt.Sprintf("%s/resize?width=%d&height=%d&quality=%d&type=%s",
+ c.baseURL, opts.Width, opts.Height, opts.Quality, opts.Type)
+
+ req, err := http.NewRequest("POST", url, &body)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ errBody, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("imaginary error %d: %s", resp.StatusCode, string(errBody))
+ }
+
+ return io.ReadAll(resp.Body)
+}
diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go
new file mode 100644
index 0000000..77c0da3
--- /dev/null
+++ b/internal/markdown/markdown.go
@@ -0,0 +1,72 @@
+package markdown
+
+import (
+ "bytes"
+ "sync"
+
+ "github.com/yuin/goldmark"
+ emoji "github.com/yuin/goldmark-emoji"
+ highlighting "github.com/yuin/goldmark-highlighting/v2"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer/html"
+)
+
+var (
+ renderers = make(map[string]goldmark.Markdown)
+ renderersMu sync.RWMutex
+)
+
+func getRenderer(codeTheme string) goldmark.Markdown {
+ if codeTheme == "" {
+ codeTheme = "github"
+ }
+
+ renderersMu.RLock()
+ if md, ok := renderers[codeTheme]; ok {
+ renderersMu.RUnlock()
+ return md
+ }
+ renderersMu.RUnlock()
+
+ renderersMu.Lock()
+ defer renderersMu.Unlock()
+
+ if md, ok := renderers[codeTheme]; ok {
+ return md
+ }
+
+ md := goldmark.New(
+ goldmark.WithExtensions(
+ extension.GFM,
+ extension.Typographer,
+ emoji.Emoji,
+ highlighting.NewHighlighting(
+ highlighting.WithStyle(codeTheme),
+ ),
+ ),
+ goldmark.WithParserOptions(
+ parser.WithAutoHeadingID(),
+ ),
+ goldmark.WithRendererOptions(
+ html.WithHardWraps(),
+ html.WithXHTML(),
+ html.WithUnsafe(),
+ ),
+ )
+ renderers[codeTheme] = md
+ return md
+}
+
+func Render(source string) (string, error) {
+ return RenderWithTheme(source, "github")
+}
+
+func RenderWithTheme(source, codeTheme string) (string, error) {
+ md := getRenderer(codeTheme)
+ var buf bytes.Buffer
+ if err := md.Convert([]byte(source), &buf); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
diff --git a/internal/markdown/themes.go b/internal/markdown/themes.go
new file mode 100644
index 0000000..90e73d7
--- /dev/null
+++ b/internal/markdown/themes.go
@@ -0,0 +1,152 @@
+package markdown
+
+import (
+ "strings"
+
+ "github.com/alecthomas/chroma/v2"
+ "github.com/alecthomas/chroma/v2/styles"
+)
+
+var chromaToHljs = map[chroma.TokenType]string{
+ chroma.Keyword: "hljs-keyword",
+ chroma.KeywordConstant: "hljs-keyword",
+ chroma.KeywordDeclaration: "hljs-keyword",
+ chroma.KeywordNamespace: "hljs-keyword",
+ chroma.KeywordPseudo: "hljs-keyword",
+ chroma.KeywordReserved: "hljs-keyword",
+ chroma.KeywordType: "hljs-type",
+
+ chroma.Name: "hljs-variable",
+ chroma.NameBuiltin: "hljs-built_in",
+ chroma.NameBuiltinPseudo: "hljs-built_in",
+ chroma.NameClass: "hljs-title class_",
+ chroma.NameConstant: "hljs-variable constant_",
+ chroma.NameDecorator: "hljs-meta",
+ chroma.NameEntity: "hljs-name",
+ chroma.NameException: "hljs-title class_",
+ chroma.NameFunction: "hljs-title function_",
+ chroma.NameFunctionMagic: "hljs-title function_",
+ chroma.NameLabel: "hljs-symbol",
+ chroma.NameNamespace: "hljs-title class_",
+ chroma.NameOther: "hljs-variable",
+ chroma.NameProperty: "hljs-property",
+ chroma.NameTag: "hljs-tag",
+ chroma.NameVariable: "hljs-variable",
+ chroma.NameVariableClass: "hljs-variable",
+ chroma.NameVariableGlobal: "hljs-variable",
+ chroma.NameVariableInstance: "hljs-variable",
+ chroma.NameVariableMagic: "hljs-variable",
+ chroma.NameAttribute: "hljs-attr",
+
+ chroma.Literal: "hljs-literal",
+ chroma.LiteralDate: "hljs-number",
+
+ chroma.String: "hljs-string",
+ chroma.StringAffix: "hljs-string",
+ chroma.StringBacktick: "hljs-string",
+ chroma.StringChar: "hljs-string",
+ chroma.StringDelimiter: "hljs-string",
+ chroma.StringDoc: "hljs-string",
+ chroma.StringDouble: "hljs-string",
+ chroma.StringEscape: "hljs-char escape_",
+ chroma.StringHeredoc: "hljs-string",
+ chroma.StringInterpol: "hljs-subst",
+ chroma.StringOther: "hljs-string",
+ chroma.StringRegex: "hljs-regexp",
+ chroma.StringSingle: "hljs-string",
+ chroma.StringSymbol: "hljs-symbol",
+
+ chroma.Number: "hljs-number",
+ chroma.NumberBin: "hljs-number",
+ chroma.NumberFloat: "hljs-number",
+ chroma.NumberHex: "hljs-number",
+ chroma.NumberInteger: "hljs-number",
+ chroma.NumberIntegerLong: "hljs-number",
+ chroma.NumberOct: "hljs-number",
+
+ chroma.Operator: "hljs-operator",
+ chroma.OperatorWord: "hljs-keyword",
+
+ chroma.Punctuation: "hljs-punctuation",
+
+ chroma.Comment: "hljs-comment",
+ chroma.CommentHashbang: "hljs-meta",
+ chroma.CommentMultiline: "hljs-comment",
+ chroma.CommentPreproc: "hljs-meta",
+ chroma.CommentPreprocFile: "hljs-meta",
+ chroma.CommentSingle: "hljs-comment",
+ chroma.CommentSpecial: "hljs-doctag",
+
+ chroma.Generic: "hljs-code",
+ chroma.GenericDeleted: "hljs-deletion",
+ chroma.GenericEmph: "hljs-emphasis",
+ chroma.GenericError: "hljs-strong",
+ chroma.GenericHeading: "hljs-section",
+ chroma.GenericInserted: "hljs-addition",
+ chroma.GenericOutput: "hljs-code",
+ chroma.GenericPrompt: "hljs-meta prompt_",
+ chroma.GenericStrong: "hljs-strong",
+ chroma.GenericSubheading: "hljs-section",
+ chroma.GenericTraceback: "hljs-code",
+ chroma.GenericUnderline: "hljs-code",
+
+ chroma.Text: "",
+ chroma.TextWhitespace: "",
+}
+
+func GenerateHljsCSS(themeName string) (string, error) {
+ style := styles.Get(themeName)
+ if style == nil {
+ style = styles.Get("github")
+ }
+
+ var css strings.Builder
+ css.WriteString("/* Auto-generated from Chroma theme: " + themeName + " */\n")
+
+ bg := style.Get(chroma.Background)
+ if bg.Background.IsSet() {
+ css.WriteString(".prose pre { background: " + bg.Background.String() + "; }\n")
+ }
+ if bg.Colour.IsSet() {
+ css.WriteString(".prose pre code { color: " + bg.Colour.String() + "; }\n")
+ }
+
+ seen := make(map[string]bool)
+
+ for chromaToken, hljsClass := range chromaToHljs {
+ if hljsClass == "" {
+ continue
+ }
+
+ entry := style.Get(chromaToken)
+ if !entry.Colour.IsSet() && entry.Bold != chroma.Yes && entry.Italic != chroma.Yes {
+ continue
+ }
+
+ if seen[hljsClass] {
+ continue
+ }
+ seen[hljsClass] = true
+
+ selector := "." + strings.ReplaceAll(hljsClass, " ", ".")
+ css.WriteString(selector + " {")
+
+ if entry.Colour.IsSet() {
+ css.WriteString(" color: " + entry.Colour.String() + ";")
+ }
+ if entry.Bold == chroma.Yes {
+ css.WriteString(" font-weight: bold;")
+ }
+ if entry.Italic == chroma.Yes {
+ css.WriteString(" font-style: italic;")
+ }
+
+ css.WriteString(" }\n")
+ }
+
+ return css.String(), nil
+}
+
+func ListThemes() []string {
+ return styles.Names()
+}
diff --git a/internal/og/og.go b/internal/og/og.go
new file mode 100644
index 0000000..98e3037
--- /dev/null
+++ b/internal/og/og.go
@@ -0,0 +1,185 @@
+package og
+
+import (
+ "bytes"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/png"
+
+ "github.com/golang/freetype"
+ "github.com/golang/freetype/truetype"
+ "golang.org/x/image/font"
+ "golang.org/x/image/font/gofont/goregular"
+ "golang.org/x/image/font/gofont/gobold"
+)
+
+const (
+ imgWidth = 1200
+ imgHeight = 630
+)
+
+var (
+ regularFont *truetype.Font
+ boldFont *truetype.Font
+)
+
+func init() {
+ var err error
+ regularFont, err = freetype.ParseFont(goregular.TTF)
+ if err != nil {
+ panic(err)
+ }
+ boldFont, err = freetype.ParseFont(gobold.TTF)
+ if err != nil {
+ panic(err)
+ }
+}
+
+func parseHexColor(hex string) color.RGBA {
+ if len(hex) == 0 {
+ return color.RGBA{16, 185, 129, 255} // #10b981 emerald
+ }
+ if hex[0] == '#' {
+ hex = hex[1:]
+ }
+ if len(hex) != 6 {
+ return color.RGBA{16, 185, 129, 255} // #10b981 emerald
+ }
+
+ var r, g, b uint8
+ for i, c := range hex {
+ var v uint8
+ switch {
+ case c >= '0' && c <= '9':
+ v = uint8(c - '0')
+ case c >= 'a' && c <= 'f':
+ v = uint8(c - 'a' + 10)
+ case c >= 'A' && c <= 'F':
+ v = uint8(c - 'A' + 10)
+ }
+ switch i {
+ case 0:
+ r = v << 4
+ case 1:
+ r |= v
+ case 2:
+ g = v << 4
+ case 3:
+ g |= v
+ case 4:
+ b = v << 4
+ case 5:
+ b |= v
+ }
+ }
+ return color.RGBA{r, g, b, 255}
+}
+
+func Generate(title, siteName, accentColor string) ([]byte, error) {
+ accent := parseHexColor(accentColor)
+
+ img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
+
+ for y := 0; y < imgHeight; y++ {
+ ratio := float64(y) / float64(imgHeight)
+ r := uint8(float64(accent.R) * (1 - ratio*0.3))
+ g := uint8(float64(accent.G) * (1 - ratio*0.3))
+ b := uint8(float64(accent.B) * (1 - ratio*0.3))
+ for x := 0; x < imgWidth; x++ {
+ img.Set(x, y, color.RGBA{r, g, b, 255})
+ }
+ }
+
+ c := freetype.NewContext()
+ c.SetDPI(72)
+ c.SetClip(img.Bounds())
+ c.SetDst(img)
+ c.SetSrc(image.White)
+ c.SetHinting(font.HintingFull)
+
+ titleSize := 64.0
+ if len(title) > 50 {
+ titleSize = 48.0
+ }
+ if len(title) > 80 {
+ titleSize = 40.0
+ }
+
+ c.SetFont(boldFont)
+ c.SetFontSize(titleSize)
+
+ wrapped := wrapText(title, 28)
+ y := 200
+ for _, line := range wrapped {
+ pt := freetype.Pt(80, y)
+ c.DrawString(line, pt)
+ y += int(titleSize * 1.4)
+ }
+
+ c.SetFont(regularFont)
+ c.SetFontSize(28)
+ pt := freetype.Pt(80, imgHeight-80)
+ c.DrawString(siteName, pt)
+
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+func wrapText(text string, maxChars int) []string {
+ if len(text) <= maxChars {
+ return []string{text}
+ }
+
+ var lines []string
+ words := splitWords(text)
+ var current string
+
+ for _, word := range words {
+ if current == "" {
+ current = word
+ } else if len(current)+1+len(word) <= maxChars {
+ current += " " + word
+ } else {
+ lines = append(lines, current)
+ current = word
+ }
+ }
+ if current != "" {
+ lines = append(lines, current)
+ }
+
+ if len(lines) > 4 {
+ lines = lines[:4]
+ if len(lines[3]) > 3 {
+ lines[3] = lines[3][:len(lines[3])-3] + "..."
+ }
+ }
+
+ return lines
+}
+
+func splitWords(s string) []string {
+ var words []string
+ var current string
+ for _, r := range s {
+ 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
+}
+
+var _ = draw.Draw
diff --git a/internal/server/api.go b/internal/server/api.go
new file mode 100644
index 0000000..59ce9a1
--- /dev/null
+++ b/internal/server/api.go
@@ -0,0 +1,208 @@
+package server
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/writekitapp/writekit/internal/auth"
+ "github.com/writekitapp/writekit/internal/tenant"
+)
+
+func (s *Server) publicAPIRoutes() chi.Router {
+ r := chi.NewRouter()
+ r.Use(s.apiKeyMiddleware)
+ r.Use(s.apiRateLimitMiddleware(s.rateLimiter))
+
+ r.Get("/posts", s.apiListPosts)
+ r.Post("/posts", s.apiCreatePost)
+ r.Get("/posts/{slug}", s.apiGetPost)
+ r.Put("/posts/{slug}", s.apiUpdatePost)
+ r.Delete("/posts/{slug}", s.apiDeletePost)
+
+ r.Get("/settings", s.apiGetSettings)
+
+ return r
+}
+
+func (s *Server) apiKeyMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ tenantID, ok := r.Context().Value(tenantIDKey).(string)
+ if !ok || tenantID == "" {
+ jsonError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ key := extractAPIKey(r)
+ if key != "" {
+ db, err := s.tenantPool.Get(tenantID)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "database error")
+ return
+ }
+
+ q := tenant.NewQueries(db)
+ valid, err := q.ValidateAPIKey(r.Context(), key)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "validation error")
+ return
+ }
+ if valid {
+ next.ServeHTTP(w, r)
+ return
+ }
+ jsonError(w, http.StatusUnauthorized, "invalid API key")
+ return
+ }
+
+ if GetDemoInfo(r).IsDemo {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ userID := auth.GetUserID(r)
+ if userID == "" {
+ jsonError(w, http.StatusUnauthorized, "API key required")
+ return
+ }
+
+ isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
+ if err != nil || !isOwner {
+ jsonError(w, http.StatusUnauthorized, "API key required")
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+func extractAPIKey(r *http.Request) string {
+ auth := r.Header.Get("Authorization")
+ if strings.HasPrefix(auth, "Bearer ") {
+ return strings.TrimPrefix(auth, "Bearer ")
+ }
+ return r.URL.Query().Get("api_key")
+}
+
+type paginatedPostsResponse struct {
+ Posts []postResponse `json:"posts"`
+ Total int `json:"total"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+}
+
+func (s *Server) apiListPosts(w http.ResponseWriter, r *http.Request) {
+ tenantID := r.Context().Value(tenantIDKey).(string)
+
+ db, err := s.tenantPool.Get(tenantID)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "database error")
+ return
+ }
+
+ limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+ offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ tag := r.URL.Query().Get("tag")
+ includeContent := r.URL.Query().Get("include") == "content"
+
+ q := tenant.NewQueries(db)
+ result, err := q.ListPostsPaginated(r.Context(), tenant.ListPostsOptions{
+ Limit: limit,
+ Offset: offset,
+ Tag: tag,
+ })
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "failed to list posts")
+ return
+ }
+
+ posts := make([]postResponse, len(result.Posts))
+ for i, p := range result.Posts {
+ posts[i] = postToResponse(&p, includeContent)
+ }
+
+ jsonResponse(w, http.StatusOK, paginatedPostsResponse{
+ Posts: posts,
+ Total: result.Total,
+ Limit: limit,
+ Offset: offset,
+ })
+}
+
+func (s *Server) apiGetPost(w http.ResponseWriter, r *http.Request) {
+ tenantID := r.Context().Value(tenantIDKey).(string)
+ slug := chi.URLParam(r, "slug")
+
+ db, err := s.tenantPool.Get(tenantID)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "database error")
+ return
+ }
+
+ q := tenant.NewQueries(db)
+ post, err := q.GetPost(r.Context(), slug)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "failed to get post")
+ return
+ }
+ if post == nil {
+ jsonError(w, http.StatusNotFound, "post not found")
+ return
+ }
+
+ jsonResponse(w, http.StatusOK, postToResponse(post, true))
+}
+
+func (s *Server) apiCreatePost(w http.ResponseWriter, r *http.Request) {
+ s.createPost(w, r)
+}
+
+func (s *Server) apiUpdatePost(w http.ResponseWriter, r *http.Request) {
+ s.updatePost(w, r)
+}
+
+func (s *Server) apiDeletePost(w http.ResponseWriter, r *http.Request) {
+ s.deletePost(w, r)
+}
+
+var publicSettingsKeys = []string{
+ "site_name",
+ "site_description",
+ "author_name",
+ "author_role",
+ "author_bio",
+ "author_photo",
+ "twitter_handle",
+ "github_handle",
+ "linkedin_handle",
+ "email",
+ "accent_color",
+ "font",
+}
+
+func (s *Server) apiGetSettings(w http.ResponseWriter, r *http.Request) {
+ tenantID := r.Context().Value(tenantIDKey).(string)
+
+ db, err := s.tenantPool.Get(tenantID)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "database error")
+ return
+ }
+
+ q := tenant.NewQueries(db)
+ allSettings, err := q.GetSettings(r.Context())
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "failed to get settings")
+ return
+ }
+
+ result := make(map[string]string)
+ for _, key := range publicSettingsKeys {
+ if val, ok := allSettings[key]; ok {
+ result[key] = val
+ }
+ }
+
+ jsonResponse(w, http.StatusOK, result)
+}
diff --git a/internal/server/blog.go b/internal/server/blog.go
new file mode 100644
index 0000000..8b873d5
--- /dev/null
+++ b/internal/server/blog.go
@@ -0,0 +1,703 @@
+package server
+
+import (
+ "bytes"
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/writekitapp/writekit/internal/auth"
+ "github.com/writekitapp/writekit/internal/build/assets"
+ "github.com/writekitapp/writekit/internal/build/templates"
+ "github.com/writekitapp/writekit/internal/config"
+ "github.com/writekitapp/writekit/internal/markdown"
+ "github.com/writekitapp/writekit/internal/tenant"
+ "github.com/writekitapp/writekit/studio"
+)
+
+func (s *Server) serveBlog(w http.ResponseWriter, r *http.Request, subdomain string) {
+ var tenantID string
+ var demoInfo DemoInfo
+
+ tenantID, ok := s.tenantCache.Get(subdomain)
+ if !ok {
+ t, err := s.database.GetTenantBySubdomain(r.Context(), subdomain)
+ if err != nil || t == nil {
+ d, err := s.database.GetDemoBySubdomain(r.Context(), subdomain)
+ if err != nil || d == nil {
+ s.notFound(w, r)
+ return
+ }
+ tenantID = d.ID
+ demoInfo = DemoInfo{IsDemo: true, ExpiresAt: d.ExpiresAt}
+ s.tenantPool.MarkAsDemo(tenantID)
+ s.ensureDemoSeeded(tenantID)
+ } else {
+ tenantID = t.ID
+ }
+ s.tenantCache.Set(subdomain, tenantID)
+ } else {
+ d, _ := s.database.GetDemoBySubdomain(r.Context(), subdomain)
+ if d != nil {
+ demoInfo = DemoInfo{IsDemo: true, ExpiresAt: d.ExpiresAt}
+ s.tenantPool.MarkAsDemo(tenantID)
+ }
+ }
+
+ ctx := context.WithValue(r.Context(), tenantIDKey, tenantID)
+ ctx = context.WithValue(ctx, demoInfoKey, demoInfo)
+ r = r.WithContext(ctx)
+
+ mux := chi.NewRouter()
+
+ mux.Get("/", s.blogHome)
+ mux.Get("/posts", s.blogList)
+ mux.Get("/posts/{slug}", s.blogPost)
+
+ mux.Handle("/static/*", http.StripPrefix("/static/", assets.Handler()))
+
+ mux.Route("/api/studio", func(r chi.Router) {
+ r.Use(demoAwareSessionMiddleware(s.database))
+ r.Use(s.ownerMiddleware)
+ r.Mount("/", s.studioRoutes())
+ })
+
+ mux.Mount("/api/v1", s.publicAPIRoutes())
+ mux.Mount("/api/reader", s.readerRoutes())
+
+ mux.Get("/studio", s.serveStudio)
+ mux.Get("/studio/*", s.serveStudio)
+
+ mux.Get("/sitemap.xml", s.sitemap)
+ mux.Get("/robots.txt", s.robots)
+
+ mux.ServeHTTP(w, r)
+}
+
+func (s *Server) blogHome(w http.ResponseWriter, r *http.Request) {
+ tenantID := r.Context().Value(tenantIDKey).(string)
+
+ db, err := s.tenantPool.Get(tenantID)
+ if err != nil {
+ slog.Error("blogHome: get tenant pool", "error", err, "tenantID", tenantID)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ q := tenant.NewQueries(db)
+ s.recordPageView(q, r, "/", "")
+
+ if html, etag, err := q.GetPage(r.Context(), "/"); err == nil && html != nil {
+ s.servePreRendered(w, r, html, etag, "public, max-age=300")
+ return
+ }
+
+ posts, err := q.ListPosts(r.Context(), false)
+ if err != nil {
+ slog.Error("blogHome: list posts", "error", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ settings, _ := q.GetSettings(r.Context())
+ siteName := getSettingOr(settings, "site_name", "My Blog")
+ siteDesc := getSettingOr(settings, "site_description", "")
+ baseURL := getBaseURL(r.Host)
+
+ showBadge := true
+ if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil {
+ tierInfo := config.GetTierInfo(t.Premium)
+ showBadge = tierInfo.Config.BadgeRequired
+ }
+
+ postSummaries := make([]templates.PostSummary, 0, len(posts))
+ for _, p := range posts {
+ if len(postSummaries) >= 10 {
+ break
+ }
+ postSummaries = append(postSummaries, templates.PostSummary{
+ Slug: p.Slug,
+ Title: p.Title,
+ Description: p.Description,
+ Date: timeOrZero(p.PublishedAt),
+ })
+ }
+
+ data := templates.HomeData{
+ PageData: templates.PageData{
+ Title: siteName,
+ Description: siteDesc,
+ CanonicalURL: baseURL + "/",
+ OGType: "website",
+ SiteName: siteName,
+ Year: time.Now().Year(),
+ Settings: settingsToMap(settings),
+ NoIndex: GetDemoInfo(r).IsDemo,
+ ShowBadge: showBadge,
+ },
+ Posts: postSummaries,
+ HasMore: len(posts) > 10,
+ }
+
+ html, err := templates.RenderHome(data)
+ if err != nil {
+ slog.Error("blogHome: render template", "error", err)
+ http.Error(w, "render error", http.StatusInternalServerError)
+ return
+ }
+
+ s.servePreRendered(w, r, html, computeETag(html), "public, max-age=300")
+}
+
+func (s *Server) blogList(w http.ResponseWriter, r *http.Request) {
+ tenantID := r.Context().Value(tenantIDKey).(string)
+
+ db, err := s.tenantPool.Get(tenantID)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ q := tenant.NewQueries(db)
+ s.recordPageView(q, r, "/posts", "")
+
+ if html, etag, err := q.GetPage(r.Context(), "/posts"); err == nil && html != nil {
+ s.servePreRendered(w, r, html, etag, "public, max-age=300")
+ return
+ }
+
+ posts, err := q.ListPosts(r.Context(), false)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ settings, _ := q.GetSettings(r.Context())
+ siteName := getSettingOr(settings, "site_name", "My Blog")
+ baseURL := getBaseURL(r.Host)
+
+ showBadge := true
+ if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil {
+ tierInfo := config.GetTierInfo(t.Premium)
+ showBadge = tierInfo.Config.BadgeRequired
+ }
+
+ postSummaries := make([]templates.PostSummary, len(posts))
+ for i, p := range posts {
+ postSummaries[i] = templates.PostSummary{
+ Slug: p.Slug,
+ Title: p.Title,
+ Description: p.Description,
+ Date: timeOrZero(p.PublishedAt),
+ }
+ }
+
+ data := templates.BlogData{
+ PageData: templates.PageData{
+ Title: "Posts - " + siteName,
+ Description: "All posts",
+ CanonicalURL: baseURL + "/posts",
+ OGType: "website",
+ SiteName: siteName,
+ Year: time.Now().Year(),
+ Settings: settingsToMap(settings),
+ NoIndex: GetDemoInfo(r).IsDemo,
+ ShowBadge: showBadge,
+ },
+ Posts: postSummaries,
+ }
+
+ html, err := templates.RenderBlog(data)
+ if err != nil {
+ http.Error(w, "render error", http.StatusInternalServerError)
+ return
+ }
+
+ s.servePreRendered(w, r, html, computeETag(html), "public, max-age=300")
+}
+
+func (s *Server) blogPost(w http.ResponseWriter, r *http.Request) {
+ tenantID := r.Context().Value(tenantIDKey).(string)
+ slug := chi.URLParam(r, "slug")
+ isPreview := r.URL.Query().Get("preview") == "true"
+
+ db, err := s.tenantPool.Get(tenantID)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ q := tenant.NewQueries(db)
+
+ if isPreview && !s.canPreview(r, tenantID) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if !isPreview {
+ path := "/posts/" + slug
+ s.recordPageView(q, r, path, slug)
+
+ if html, etag, err := q.GetPage(r.Context(), path); err == nil && html != nil {
+ s.servePreRendered(w, r, html, etag, "public, max-age=3600")
+ return
+ }
+ }
+
+ post, err := q.GetPost(r.Context(), slug)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if post == nil {
+ aliasPost, _ := q.GetPostByAlias(r.Context(), slug)
+ if aliasPost != nil && aliasPost.IsPublished {
+ http.Redirect(w, r, "/posts/"+aliasPost.Slug, http.StatusMovedPermanently)
+ return
+ }
+ http.NotFound(w, r)
+ return
+ }
+
+ if !post.IsPublished && !isPreview {
+ http.NotFound(w, r)
+ return
+ }
+
+ title := post.Title
+ description := post.Description
+ contentMD := post.ContentMD
+ tags := post.Tags
+ coverImage := post.CoverImage
+
+ if isPreview {
+ if draft, _ := q.GetDraft(r.Context(), post.ID); draft != nil {
+ title = draft.Title
+ description = draft.Description
+ contentMD = draft.ContentMD
+ tags = draft.Tags
+ coverImage = draft.CoverImage
+ }
+ }
+
+ settings, _ := q.GetSettings(r.Context())
+ siteName := getSettingOr(settings, "site_name", "My Blog")
+ baseURL := getBaseURL(r.Host)
+ codeTheme := getSettingOr(settings, "code_theme", "github")
+
+ showBadge := true
+ if t, err := s.database.GetTenantByID(r.Context(), tenantID); err == nil && t != nil {
+ tierInfo := config.GetTierInfo(t.Premium)
+ showBadge = tierInfo.Config.BadgeRequired
+ }
+
+ contentHTML := ""
+ if contentMD != "" {
+ contentHTML, _ = markdown.RenderWithTheme(contentMD, codeTheme)
+ }
+
+ interactionConfig := q.GetInteractionConfig(r.Context())
+ structuredData := buildArticleSchema(post, siteName, baseURL)
+
+ data := templates.PostData{
+ PageData: templates.PageData{
+ Title: title + " - " + siteName,
+ Description: description,
+ CanonicalURL: baseURL + "/posts/" + post.Slug,
+ OGType: "article",
+ OGImage: coverImage,
+ SiteName: siteName,
+ Year: time.Now().Year(),
+ StructuredData: template.JS(structuredData),
+ Settings: settingsToMap(settings),
+ NoIndex: GetDemoInfo(r).IsDemo || isPreview,
+ ShowBadge: showBadge,
+ },
+ Post: templates.PostDetail{
+ Slug: post.Slug,
+ Title: title,
+ Description: description,
+ CoverImage: coverImage,
+ Date: timeOrZero(post.PublishedAt),
+ Tags: tags,
+ },
+ ContentHTML: template.HTML(contentHTML),
+ InteractionConfig: interactionConfig,
+ }
+
+ html, err := templates.RenderPost(data)
+ if err != nil {
+ http.Error(w, "render error", http.StatusInternalServerError)
+ return
+ }
+
+ if isPreview {
+ previewScript := `
+
+Rebuilding...
+`
+ html = bytes.Replace(html, []byte("
+Dashboard
+Create your blog or manage your existing one.
+"), []byte(previewScript+""), 1)
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(html)
+ return
+ }
+
+ s.servePreRendered(w, r, html, computeETag(html), "public, max-age=3600")
+}
+
+func (s *Server) canPreview(r *http.Request, tenantID string) bool {
+ if GetDemoInfo(r).IsDemo {
+ return true
+ }
+
+ userID := auth.GetUserID(r)
+ if userID == "" {
+ return false
+ }
+
+ isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
+ if err != nil {
+ return false
+ }
+ return isOwner
+}
+
+func (s *Server) serveStudio(w http.ResponseWriter, r *http.Request) {
+ if viteURL := os.Getenv("VITE_URL"); viteURL != "" && os.Getenv("ENV") == "local" {
+ target, err := url.Parse(viteURL)
+ if err != nil {
+ slog.Error("invalid VITE_URL", "error", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ proxy := httputil.NewSingleHostReverseProxy(target)
+ proxy.Director = func(req *http.Request) {
+ req.URL.Scheme = target.Scheme
+ req.URL.Host = target.Host
+ req.Host = target.Host
+ }
+ proxy.ServeHTTP(w, r)
+ return
+ }
+
+ path := chi.URLParam(r, "*")
+ if path == "" {
+ path = "index.html"
+ }
+
+ data, err := studio.Read(path)
+ if err != nil {
+ data, _ = studio.Read("index.html")
+ }
+
+ contentType := "text/html; charset=utf-8"
+ if len(path) > 3 {
+ switch path[len(path)-3:] {
+ case ".js":
+ contentType = "application/javascript"
+ case "css":
+ contentType = "text/css"
+ }
+ }
+
+ if contentType == "text/html; charset=utf-8" {
+ if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
+ data = s.injectDemoBanner(data, demoInfo.ExpiresAt)
+ }
+ }
+
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ if contentType == "text/html; charset=utf-8" {
+ w.Header().Set("Cache-Control", "no-cache")
+ }
+ w.Write(data)
+}
+
+func (s *Server) sitemap(w http.ResponseWriter, r *http.Request) {
+ tenantID := r.Context().Value(tenantIDKey).(string)
+
+ db, err := s.tenantPool.Get(tenantID)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ q := tenant.NewQueries(db)
+ posts, _ := q.ListPosts(r.Context(), false)
+ baseURL := getBaseURL(r.Host)
+
+ w.Header().Set("Content-Type", "application/xml; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+
+ w.Write([]byte(`
+
+ ` + baseURL + `/
+`))
+
+ for _, p := range posts {
+ lastmod := p.ModifiedAt.Format("2006-01-02")
+ if p.UpdatedAt != nil {
+ lastmod = p.UpdatedAt.Format("2006-01-02")
+ }
+ w.Write([]byte(fmt.Sprintf(" %s/posts/%s%s\n",
+ baseURL, p.Slug, lastmod)))
+ }
+
+ w.Write([]byte(""))
+}
+
+func (s *Server) robots(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+
+ if GetDemoInfo(r).IsDemo {
+ w.Write([]byte("User-agent: *\nDisallow: /\n"))
+ return
+ }
+
+ baseURL := getBaseURL(r.Host)
+ fmt.Fprintf(w, "User-agent: *\nAllow: /\n\nSitemap: %s/sitemap.xml\n", baseURL)
+}
+
+func (s *Server) ownerMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ demoInfo := GetDemoInfo(r)
+ if demoInfo.IsDemo {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ userID := auth.GetUserID(r)
+ if userID == "" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ tenantID, ok := r.Context().Value(tenantIDKey).(string)
+ if !ok || tenantID == "" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ isOwner, err := s.database.IsUserTenantOwner(r.Context(), userID, tenantID)
+ if err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !isOwner {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+func getSettingOr(settings tenant.Settings, key, fallback string) string {
+ if v, ok := settings[key]; ok && v != "" {
+ return v
+ }
+ return fallback
+}
+
+func settingsToMap(settings tenant.Settings) map[string]any {
+ m := make(map[string]any)
+ for k, v := range settings {
+ m[k] = v
+ }
+ return m
+}
+
+func getBaseURL(host string) string {
+ scheme := "https"
+ if env := os.Getenv("ENV"); env != "prod" {
+ scheme = "http"
+ }
+ return fmt.Sprintf("%s://%s", scheme, host)
+}
+
+func computeETag(data []byte) string {
+ hash := md5.Sum(data)
+ return `"` + hex.EncodeToString(hash[:]) + `"`
+}
+
+func (s *Server) servePreRendered(w http.ResponseWriter, r *http.Request, html []byte, etag, cacheControl string) {
+ if demoInfo := GetDemoInfo(r); demoInfo.IsDemo {
+ html = s.injectDemoBanner(html, demoInfo.ExpiresAt)
+ etag = computeETag(html)
+ }
+
+ if match := r.Header.Get("If-None-Match"); match == etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", cacheControl)
+ w.Header().Set("ETag", etag)
+ w.Write(html)
+}
+
+func buildArticleSchema(post *tenant.Post, siteName, baseURL string) string {
+ publishedAt := timeOrZero(post.PublishedAt)
+ modifiedAt := publishedAt
+ if post.UpdatedAt != nil {
+ modifiedAt = *post.UpdatedAt
+ }
+
+ schema := map[string]any{
+ "@context": "https://schema.org",
+ "@type": "Article",
+ "headline": post.Title,
+ "datePublished": publishedAt.Format(time.RFC3339),
+ "dateModified": modifiedAt.Format(time.RFC3339),
+ "author": map[string]any{
+ "@type": "Person",
+ "name": siteName,
+ },
+ "publisher": map[string]any{
+ "@type": "Organization",
+ "name": siteName,
+ },
+ "mainEntityOfPage": map[string]any{
+ "@type": "WebPage",
+ "@id": baseURL + "/posts/" + post.Slug,
+ },
+ }
+ if post.Description != "" {
+ schema["description"] = post.Description
+ }
+ b, _ := json.Marshal(schema)
+ return string(b)
+}
+
+func (s *Server) recordPageView(q *tenant.Queries, r *http.Request, path, postSlug string) {
+ referrer := r.Header.Get("Referer")
+ userAgent := r.Header.Get("User-Agent")
+ go func() {
+ q.RecordPageView(context.Background(), path, postSlug, referrer, userAgent)
+ }()
+}
+
+func timeOrZero(t *time.Time) time.Time {
+ if t == nil {
+ return time.Time{}
+ }
+ return *t
+}
diff --git a/internal/server/build.go b/internal/server/build.go
new file mode 100644
index 0000000..116d845
--- /dev/null
+++ b/internal/server/build.go
@@ -0,0 +1,176 @@
+package server
+
+import (
+ "context"
+ "database/sql"
+ "html/template"
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/writekitapp/writekit/internal/build/templates"
+ "github.com/writekitapp/writekit/internal/markdown"
+ "github.com/writekitapp/writekit/internal/tenant"
+)
+
+type renderedPage struct {
+ path string
+ html []byte
+ etag string
+}
+
+func (s *Server) rebuildSite(ctx context.Context, tenantID string, db *sql.DB, host string) {
+ q := tenant.NewQueries(db)
+ settings, err := q.GetSettings(ctx)
+ if err != nil {
+ slog.Error("rebuildSite: get settings", "error", err, "tenantID", tenantID)
+ return
+ }
+
+ posts, err := q.ListPosts(ctx, false)
+ if err != nil {
+ slog.Error("rebuildSite: list posts", "error", err, "tenantID", tenantID)
+ return
+ }
+
+ baseURL := getBaseURL(host)
+ siteName := getSettingOr(settings, "site_name", "My Blog")
+ siteDesc := getSettingOr(settings, "site_description", "")
+ codeTheme := getSettingOr(settings, "code_theme", "github")
+ fontKey := getSettingOr(settings, "font", "system")
+ isDemo := getSettingOr(settings, "is_demo", "") == "true"
+
+ pageData := templates.PageData{
+ SiteName: siteName,
+ Year: time.Now().Year(),
+ FontURL: templates.GetFontURL(fontKey),
+ FontFamily: templates.GetFontFamily(fontKey),
+ Settings: settingsToMap(settings),
+ NoIndex: isDemo,
+ }
+
+ var pages []renderedPage
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+
+ addPage := func(path string, html []byte) {
+ mu.Lock()
+ pages = append(pages, renderedPage{path, html, computeETag(html)})
+ mu.Unlock()
+ }
+
+ postSummaries := make([]templates.PostSummary, len(posts))
+ for i, p := range posts {
+ postSummaries[i] = templates.PostSummary{
+ Slug: p.Slug,
+ Title: p.Title,
+ Description: p.Description,
+ Date: timeOrZero(p.PublishedAt),
+ }
+ }
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ homePosts := postSummaries
+ if len(homePosts) > 10 {
+ homePosts = homePosts[:10]
+ }
+ data := templates.HomeData{
+ PageData: pageData,
+ Posts: homePosts,
+ HasMore: len(postSummaries) > 10,
+ }
+ data.Title = siteName
+ data.Description = siteDesc
+ data.CanonicalURL = baseURL + "/"
+ data.OGType = "website"
+
+ html, err := templates.RenderHome(data)
+ if err != nil {
+ slog.Error("rebuildSite: render home", "error", err)
+ return
+ }
+ addPage("/", html)
+ }()
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ data := templates.BlogData{
+ PageData: pageData,
+ Posts: postSummaries,
+ }
+ data.Title = "Posts - " + siteName
+ data.Description = "All posts"
+ data.CanonicalURL = baseURL + "/posts"
+ data.OGType = "website"
+
+ html, err := templates.RenderBlog(data)
+ if err != nil {
+ slog.Error("rebuildSite: render blog", "error", err)
+ return
+ }
+ addPage("/posts", html)
+ }()
+
+ for _, p := range posts {
+ wg.Add(1)
+ go func(post tenant.Post) {
+ defer wg.Done()
+
+ contentHTML := post.ContentHTML
+ if contentHTML == "" && post.ContentMD != "" {
+ contentHTML, _ = markdown.RenderWithTheme(post.ContentMD, codeTheme)
+ }
+
+ interactionConfig := q.GetInteractionConfig(ctx)
+ structuredData := buildArticleSchema(&post, siteName, baseURL)
+
+ data := templates.PostData{
+ PageData: pageData,
+ Post: templates.PostDetail{
+ Slug: post.Slug,
+ Title: post.Title,
+ Description: post.Description,
+ Date: timeOrZero(post.PublishedAt),
+ Tags: post.Tags,
+ },
+ ContentHTML: template.HTML(contentHTML),
+ InteractionConfig: interactionConfig,
+ }
+ data.Title = post.Title + " - " + siteName
+ data.Description = post.Description
+ data.CanonicalURL = baseURL + "/posts/" + post.Slug
+ data.OGType = "article"
+ data.StructuredData = template.JS(structuredData)
+
+ html, err := templates.RenderPost(data)
+ if err != nil {
+ slog.Error("rebuildSite: render post", "error", err, "slug", post.Slug)
+ return
+ }
+ addPage("/posts/"+post.Slug, html)
+ }(p)
+ }
+
+ wg.Wait()
+
+ for _, p := range pages {
+ if err := q.SetPage(ctx, p.path, p.html, p.etag); err != nil {
+ slog.Error("rebuildSite: save page", "error", err, "path", p.path)
+ }
+ }
+
+ if s.cloudflare.IsConfigured() {
+ urls := make([]string, len(pages))
+ for i, p := range pages {
+ urls[i] = baseURL + p.path
+ }
+ if err := s.cloudflare.PurgeURLs(ctx, urls); err != nil {
+ slog.Error("rebuildSite: purge cache", "error", err)
+ }
+ }
+
+ slog.Info("rebuildSite: complete", "tenantID", tenantID, "pages", len(pages))
+}
diff --git a/internal/server/demo.go b/internal/server/demo.go
new file mode 100644
index 0000000..8a4d855
--- /dev/null
+++ b/internal/server/demo.go
@@ -0,0 +1,208 @@
+package server
+
+import (
+ "bytes"
+ "math/rand"
+ "net/http"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/writekitapp/writekit/internal/auth"
+ "github.com/writekitapp/writekit/internal/db"
+ "github.com/writekitapp/writekit/internal/tenant"
+)
+
+type ctxKey string
+
+const (
+ tenantIDKey ctxKey = "tenantID"
+ demoInfoKey ctxKey = "demoInfo"
+)
+
+type DemoInfo struct {
+ IsDemo bool
+ ExpiresAt time.Time
+}
+
+func GetDemoInfo(r *http.Request) DemoInfo {
+ if info, ok := r.Context().Value(demoInfoKey).(DemoInfo); ok {
+ return info
+ }
+ return DemoInfo{}
+}
+
+func demoAwareSessionMiddleware(database *db.DB) func(http.Handler) http.Handler {
+ sessionMW := auth.SessionMiddleware(database)
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if GetDemoInfo(r).IsDemo {
+ next.ServeHTTP(w, r)
+ return
+ }
+ sessionMW(next).ServeHTTP(w, r)
+ })
+ }
+}
+
+func (s *Server) injectDemoBanner(html []byte, expiresAt time.Time) []byte {
+ scheme := "https"
+ if os.Getenv("ENV") != "prod" {
+ scheme = "http"
+ }
+ redirectURL := scheme + "://" + s.domain
+ banner := demoBannerHTML(expiresAt, redirectURL)
+ return bytes.Replace(html, []byte(""), append([]byte(banner), []byte("")...), 1)
+}
+
+func demoBannerHTML(expiresAt time.Time, redirectURL string) string {
+ expiresUnix := expiresAt.Unix()
+ return `
+
+`
+}
+
+func generateFakeAnalytics(days int) *tenant.AnalyticsSummary {
+ if days <= 0 {
+ days = 30
+ }
+
+ baseViews := 25 + rand.Intn(20)
+ var totalViews, totalVisitors int64
+ viewsByDay := make([]tenant.DailyStats, days)
+
+ for i := 0; i < days; i++ {
+ date := time.Now().AddDate(0, 0, -days+i+1)
+ weekday := date.Weekday()
+
+ multiplier := 1.0
+ if weekday == time.Saturday || weekday == time.Sunday {
+ multiplier = 0.6
+ } else if weekday == time.Monday {
+ multiplier = 1.2
+ }
+
+ dailyViews := int64(float64(baseViews+rand.Intn(15)) * multiplier)
+ dailyVisitors := dailyViews * int64(65+rand.Intn(15)) / 100
+
+ totalViews += dailyViews
+ totalVisitors += dailyVisitors
+
+ viewsByDay[i] = tenant.DailyStats{
+ Date: date.Format("2006-01-02"),
+ Views: dailyViews,
+ Visitors: dailyVisitors,
+ }
+ }
+
+ return &tenant.AnalyticsSummary{
+ TotalViews: totalViews,
+ TotalPageViews: totalViews,
+ UniqueVisitors: totalVisitors,
+ TotalBandwidth: totalViews * 45000,
+ ViewsChange: float64(rand.Intn(300)-100) / 10,
+ ViewsByDay: viewsByDay,
+ TopPages: []tenant.PageStats{
+ {Path: "/", Views: totalViews * 25 / 100},
+ {Path: "/posts/shipping-a-side-project", Views: totalViews * 22 / 100},
+ {Path: "/posts/debugging-production-like-a-detective", Views: totalViews * 18 / 100},
+ {Path: "/posts/sqlite-in-production", Views: totalViews * 15 / 100},
+ {Path: "/posts", Views: totalViews * 12 / 100},
+ {Path: "/posts/my-2024-reading-list", Views: totalViews * 8 / 100},
+ },
+ TopReferrers: []tenant.ReferrerStats{
+ {Referrer: "Google", Views: totalViews * 30 / 100},
+ {Referrer: "Twitter/X", Views: totalViews * 20 / 100},
+ {Referrer: "GitHub", Views: totalViews * 15 / 100},
+ {Referrer: "Hacker News", Views: totalViews * 12 / 100},
+ {Referrer: "LinkedIn", Views: totalViews * 10 / 100},
+ {Referrer: "YouTube", Views: totalViews * 8 / 100},
+ {Referrer: "Reddit", Views: totalViews * 5 / 100},
+ },
+ Browsers: []tenant.NamedStat{
+ {Name: "Chrome", Count: totalVisitors * 55 / 100},
+ {Name: "Safari", Count: totalVisitors * 25 / 100},
+ {Name: "Firefox", Count: totalVisitors * 12 / 100},
+ {Name: "Edge", Count: totalVisitors * 8 / 100},
+ },
+ OS: []tenant.NamedStat{
+ {Name: "macOS", Count: totalVisitors * 45 / 100},
+ {Name: "Windows", Count: totalVisitors * 30 / 100},
+ {Name: "iOS", Count: totalVisitors * 15 / 100},
+ {Name: "Linux", Count: totalVisitors * 7 / 100},
+ {Name: "Android", Count: totalVisitors * 3 / 100},
+ },
+ Devices: []tenant.NamedStat{
+ {Name: "Desktop", Count: totalVisitors * 70 / 100},
+ {Name: "Mobile", Count: totalVisitors * 25 / 100},
+ {Name: "Tablet", Count: totalVisitors * 5 / 100},
+ },
+ Countries: []tenant.NamedStat{
+ {Name: "United States", Count: totalVisitors * 40 / 100},
+ {Name: "United Kingdom", Count: totalVisitors * 12 / 100},
+ {Name: "Germany", Count: totalVisitors * 10 / 100},
+ {Name: "Canada", Count: totalVisitors * 8 / 100},
+ {Name: "France", Count: totalVisitors * 6 / 100},
+ {Name: "Australia", Count: totalVisitors * 5 / 100},
+ },
+ }
+}
+
+func generateFakePostAnalytics(days int) *tenant.AnalyticsSummary {
+ if days <= 0 {
+ days = 30
+ }
+
+ baseViews := 5 + rand.Intn(8)
+ var totalViews int64
+ viewsByDay := make([]tenant.DailyStats, days)
+
+ for i := 0; i < days; i++ {
+ date := time.Now().AddDate(0, 0, -days+i+1)
+ dailyViews := int64(baseViews + rand.Intn(6))
+ totalViews += dailyViews
+
+ viewsByDay[i] = tenant.DailyStats{
+ Date: date.Format("2006-01-02"),
+ Views: dailyViews,
+ }
+ }
+
+ return &tenant.AnalyticsSummary{
+ TotalViews: totalViews,
+ ViewsByDay: viewsByDay,
+ }
+}
diff --git a/internal/server/platform.go b/internal/server/platform.go
new file mode 100644
index 0000000..78597d8
--- /dev/null
+++ b/internal/server/platform.go
@@ -0,0 +1,649 @@
+package server
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "os"
+ "regexp"
+ "strings"
+
+ "github.com/writekitapp/writekit/internal/auth"
+)
+
+//go:embed templates/*.html
+var templatesFS embed.FS
+
+//go:embed static/*
+var staticFS embed.FS
+
+var subdomainRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`)
+
+func (s *Server) platformHome(w http.ResponseWriter, r *http.Request) {
+ content, err := templatesFS.ReadFile("templates/index.html")
+ if err != nil {
+ slog.Error("read index template", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ html := string(content)
+ html = strings.ReplaceAll(html, "{{ACCENT}}", "#10b981")
+ html = strings.ReplaceAll(html, "{{VERSION}}", "v1.0.0")
+ html = strings.ReplaceAll(html, "{{COMMIT}}", "dev")
+ html = strings.ReplaceAll(html, "{{DEMO_MINUTES}}", "15")
+
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(html))
+}
+
+func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
+ content, err := templatesFS.ReadFile("templates/404.html")
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(http.StatusNotFound)
+ w.Write(content)
+}
+
+func (s *Server) platformLogin(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/signup", http.StatusFound)
+}
+
+func (s *Server) platformSignup(w http.ResponseWriter, r *http.Request) {
+ content, err := templatesFS.ReadFile("templates/signup.html")
+ if err != nil {
+ slog.Error("read signup template", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ html := string(content)
+ html = strings.ReplaceAll(html, "{{ACCENT}}", "#10b981")
+
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(html))
+}
+
+func (s *Server) platformDashboard(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`
+
+