From d69342b2e9a0f6d795276e75aadd12757dcc540c Mon Sep 17 00:00:00 2001
From: Josh
Date: Fri, 9 Jan 2026 00:16:46 +0200
Subject: [PATCH] init
---
.air.toml | 24 +
.env.example | 79 +
.gitignore | 13 +
.woodpecker.yml | 43 +
Dockerfile | 22 +
MONETIZATION.md | 121 +
Makefile | 22 +
README.md | 61 +
docker-compose.yml | 119 +
go.mod | 67 +
go.sum | 160 +
internal/auth/middleware.go | 54 +
internal/auth/oauth.go | 535 +++
internal/billing/lemon.go | 234 ++
internal/billing/payout.go | 126 +
internal/billing/store.go | 333 ++
internal/billing/webhook.go | 303 ++
internal/billing/wise.go | 198 +
internal/build/assets/assets.go | 15 +
internal/build/assets/css/style.css | 778 ++++
internal/build/assets/js/main.js | 127 +
internal/build/assets/js/post.js | 197 +
internal/build/templates/base.html | 76 +
internal/build/templates/blog.html | 34 +
internal/build/templates/home.html | 34 +
internal/build/templates/post.html | 57 +
internal/build/templates/templates.go | 139 +
internal/cloudflare/analytics.go | 451 ++
internal/config/tiers.go | 100 +
internal/db/db.go | 81 +
internal/db/demos.go | 109 +
internal/db/migrations/001_initial.sql | 185 +
internal/db/models.go | 34 +
internal/db/sessions.go | 45 +
internal/db/tenants.go | 147 +
internal/db/users.go | 62 +
internal/imaginary/client.go | 77 +
internal/markdown/markdown.go | 72 +
internal/markdown/themes.go | 152 +
internal/og/og.go | 185 +
internal/server/api.go | 208 +
internal/server/blog.go | 703 ++++
internal/server/build.go | 176 +
internal/server/demo.go | 208 +
internal/server/platform.go | 649 +++
internal/server/ratelimit.go | 126 +
internal/server/reader.go | 634 +++
internal/server/server.go | 163 +
.../server/static/analytics-screenshot.png | Bin 0 -> 54639 bytes
internal/server/static/apple-touch-icon.png | Bin 0 -> 10597 bytes
internal/server/static/favicon-16x16.png | Bin 0 -> 1215 bytes
internal/server/static/favicon-192x192.png | Bin 0 -> 11380 bytes
internal/server/static/favicon-32x32.png | Bin 0 -> 1711 bytes
internal/server/static/writekit-icon.ico | Bin 0 -> 4286 bytes
internal/server/static/writekit-icon.png | Bin 0 -> 29942 bytes
internal/server/static/writekit-icon.svg | 24 +
internal/server/studio.go | 2052 +++++++++
internal/server/sync.go | 113 +
internal/server/templates/404.html | 64 +
internal/server/templates/expired.html | 56 +
internal/server/templates/index.html | 484 +++
internal/server/templates/signup.html | 472 +++
internal/storage/s3.go | 113 +
internal/storage/storage.go | 14 +
internal/tenant/analytics.go | 279 ++
internal/tenant/apikeys.go | 94 +
internal/tenant/assets.go | 80 +
internal/tenant/comments.go | 70 +
internal/tenant/members.go | 63 +
internal/tenant/models.go | 124 +
internal/tenant/pages.go | 39 +
internal/tenant/plugins.go | 136 +
internal/tenant/pool.go | 176 +
internal/tenant/posts.go | 481 +++
internal/tenant/queries.go | 24 +
internal/tenant/reactions.go | 159 +
internal/tenant/runner.go | 621 +++
internal/tenant/search.go | 67 +
internal/tenant/secrets.go | 202 +
internal/tenant/settings.go | 84 +
internal/tenant/sqlite.go | 273 ++
internal/tenant/sync.go | 31 +
internal/tenant/users.go | 117 +
internal/tenant/webhooks.go | 308 ++
main.go | 50 +
studio/embed.go | 20 +
studio/index.html | 16 +
studio/package-lock.json | 3732 +++++++++++++++++
studio/package.json | 49 +
studio/src/App.tsx | 92 +
studio/src/api.ts | 77 +
.../src/components/editor/MetadataPanel.tsx | 198 +
studio/src/components/editor/PluginEditor.tsx | 722 ++++
studio/src/components/editor/PostEditor.tsx | 354 ++
.../src/components/editor/SlashCommands.tsx | 270 ++
studio/src/components/editor/SourceEditor.tsx | 105 +
studio/src/components/editor/index.ts | 4 +
studio/src/components/layout/Header.tsx | 76 +
studio/src/components/layout/Sidebar.tsx | 102 +
studio/src/components/layout/index.ts | 2 +
studio/src/components/shared/Breadcrumb.tsx | 45 +
.../src/components/shared/BreakdownList.tsx | 51 +
studio/src/components/shared/EmptyState.tsx | 24 +
studio/src/components/shared/Field.tsx | 27 +
studio/src/components/shared/Icons.tsx | 316 ++
studio/src/components/shared/LoadingState.tsx | 9 +
studio/src/components/shared/PageHeader.tsx | 16 +
studio/src/components/shared/SaveBar.tsx | 39 +
studio/src/components/shared/Section.tsx | 21 +
studio/src/components/shared/Skeleton.tsx | 596 +++
studio/src/components/shared/StatCard.tsx | 26 +
studio/src/components/shared/index.ts | 23 +
studio/src/components/ui/ActionMenu.tsx | 92 +
studio/src/components/ui/Badge.tsx | 20 +
studio/src/components/ui/Button.tsx | 63 +
studio/src/components/ui/Dropdown.tsx | 86 +
studio/src/components/ui/Input.tsx | 50 +
studio/src/components/ui/Modal.tsx | 51 +
studio/src/components/ui/Select.tsx | 33 +
studio/src/components/ui/Tabs.tsx | 74 +
studio/src/components/ui/Toast.tsx | 33 +
studio/src/components/ui/Toggle.tsx | 29 +
studio/src/components/ui/UsageIndicator.tsx | 42 +
studio/src/components/ui/index.ts | 11 +
studio/src/main.tsx | 11 +
studio/src/pages/APIPage.tsx | 749 ++++
studio/src/pages/AnalyticsPage.tsx | 318 ++
studio/src/pages/BillingPage.tsx | 251 ++
studio/src/pages/DataPage.tsx | 106 +
studio/src/pages/DesignPage.preview.css | 236 ++
studio/src/pages/DesignPage.tsx | 474 +++
studio/src/pages/DomainPage.tsx | 82 +
studio/src/pages/EngagementPage.tsx | 118 +
studio/src/pages/GeneralPage.tsx | 125 +
studio/src/pages/HomePage.tsx | 229 +
studio/src/pages/MonetizationPage.tsx | 75 +
studio/src/pages/PluginsPage.tsx | 784 ++++
studio/src/pages/PostEditorPage.tsx | 285 ++
studio/src/pages/PostsPage.tsx | 302 ++
studio/src/pages/index.ts | 10 +
studio/src/stores/analytics.ts | 10 +
studio/src/stores/apiKeys.ts | 34 +
studio/src/stores/app.ts | 19 +
studio/src/stores/assets.ts | 33 +
studio/src/stores/billing.ts | 4 +
studio/src/stores/editor.ts | 467 +++
studio/src/stores/fetcher.ts | 24 +
studio/src/stores/hooks.ts | 35 +
studio/src/stores/index.ts | 9 +
studio/src/stores/interactions.ts | 74 +
studio/src/stores/plugins.ts | 102 +
studio/src/stores/posts.ts | 41 +
studio/src/stores/router.ts | 18 +
studio/src/stores/secrets.ts | 36 +
studio/src/stores/settings.ts | 93 +
studio/src/stores/webhooks.ts | 69 +
studio/src/types.ts | 128 +
studio/tsconfig.json | 20 +
studio/uno.config.ts | 259 ++
studio/vite.config.ts | 47 +
160 files changed, 28681 insertions(+)
create mode 100644 .air.toml
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 .woodpecker.yml
create mode 100644 Dockerfile
create mode 100644 MONETIZATION.md
create mode 100644 Makefile
create mode 100644 README.md
create mode 100644 docker-compose.yml
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 internal/auth/middleware.go
create mode 100644 internal/auth/oauth.go
create mode 100644 internal/billing/lemon.go
create mode 100644 internal/billing/payout.go
create mode 100644 internal/billing/store.go
create mode 100644 internal/billing/webhook.go
create mode 100644 internal/billing/wise.go
create mode 100644 internal/build/assets/assets.go
create mode 100644 internal/build/assets/css/style.css
create mode 100644 internal/build/assets/js/main.js
create mode 100644 internal/build/assets/js/post.js
create mode 100644 internal/build/templates/base.html
create mode 100644 internal/build/templates/blog.html
create mode 100644 internal/build/templates/home.html
create mode 100644 internal/build/templates/post.html
create mode 100644 internal/build/templates/templates.go
create mode 100644 internal/cloudflare/analytics.go
create mode 100644 internal/config/tiers.go
create mode 100644 internal/db/db.go
create mode 100644 internal/db/demos.go
create mode 100644 internal/db/migrations/001_initial.sql
create mode 100644 internal/db/models.go
create mode 100644 internal/db/sessions.go
create mode 100644 internal/db/tenants.go
create mode 100644 internal/db/users.go
create mode 100644 internal/imaginary/client.go
create mode 100644 internal/markdown/markdown.go
create mode 100644 internal/markdown/themes.go
create mode 100644 internal/og/og.go
create mode 100644 internal/server/api.go
create mode 100644 internal/server/blog.go
create mode 100644 internal/server/build.go
create mode 100644 internal/server/demo.go
create mode 100644 internal/server/platform.go
create mode 100644 internal/server/ratelimit.go
create mode 100644 internal/server/reader.go
create mode 100644 internal/server/server.go
create mode 100644 internal/server/static/analytics-screenshot.png
create mode 100644 internal/server/static/apple-touch-icon.png
create mode 100644 internal/server/static/favicon-16x16.png
create mode 100644 internal/server/static/favicon-192x192.png
create mode 100644 internal/server/static/favicon-32x32.png
create mode 100644 internal/server/static/writekit-icon.ico
create mode 100644 internal/server/static/writekit-icon.png
create mode 100644 internal/server/static/writekit-icon.svg
create mode 100644 internal/server/studio.go
create mode 100644 internal/server/sync.go
create mode 100644 internal/server/templates/404.html
create mode 100644 internal/server/templates/expired.html
create mode 100644 internal/server/templates/index.html
create mode 100644 internal/server/templates/signup.html
create mode 100644 internal/storage/s3.go
create mode 100644 internal/storage/storage.go
create mode 100644 internal/tenant/analytics.go
create mode 100644 internal/tenant/apikeys.go
create mode 100644 internal/tenant/assets.go
create mode 100644 internal/tenant/comments.go
create mode 100644 internal/tenant/members.go
create mode 100644 internal/tenant/models.go
create mode 100644 internal/tenant/pages.go
create mode 100644 internal/tenant/plugins.go
create mode 100644 internal/tenant/pool.go
create mode 100644 internal/tenant/posts.go
create mode 100644 internal/tenant/queries.go
create mode 100644 internal/tenant/reactions.go
create mode 100644 internal/tenant/runner.go
create mode 100644 internal/tenant/search.go
create mode 100644 internal/tenant/secrets.go
create mode 100644 internal/tenant/settings.go
create mode 100644 internal/tenant/sqlite.go
create mode 100644 internal/tenant/sync.go
create mode 100644 internal/tenant/users.go
create mode 100644 internal/tenant/webhooks.go
create mode 100644 main.go
create mode 100644 studio/embed.go
create mode 100644 studio/index.html
create mode 100644 studio/package-lock.json
create mode 100644 studio/package.json
create mode 100644 studio/src/App.tsx
create mode 100644 studio/src/api.ts
create mode 100644 studio/src/components/editor/MetadataPanel.tsx
create mode 100644 studio/src/components/editor/PluginEditor.tsx
create mode 100644 studio/src/components/editor/PostEditor.tsx
create mode 100644 studio/src/components/editor/SlashCommands.tsx
create mode 100644 studio/src/components/editor/SourceEditor.tsx
create mode 100644 studio/src/components/editor/index.ts
create mode 100644 studio/src/components/layout/Header.tsx
create mode 100644 studio/src/components/layout/Sidebar.tsx
create mode 100644 studio/src/components/layout/index.ts
create mode 100644 studio/src/components/shared/Breadcrumb.tsx
create mode 100644 studio/src/components/shared/BreakdownList.tsx
create mode 100644 studio/src/components/shared/EmptyState.tsx
create mode 100644 studio/src/components/shared/Field.tsx
create mode 100644 studio/src/components/shared/Icons.tsx
create mode 100644 studio/src/components/shared/LoadingState.tsx
create mode 100644 studio/src/components/shared/PageHeader.tsx
create mode 100644 studio/src/components/shared/SaveBar.tsx
create mode 100644 studio/src/components/shared/Section.tsx
create mode 100644 studio/src/components/shared/Skeleton.tsx
create mode 100644 studio/src/components/shared/StatCard.tsx
create mode 100644 studio/src/components/shared/index.ts
create mode 100644 studio/src/components/ui/ActionMenu.tsx
create mode 100644 studio/src/components/ui/Badge.tsx
create mode 100644 studio/src/components/ui/Button.tsx
create mode 100644 studio/src/components/ui/Dropdown.tsx
create mode 100644 studio/src/components/ui/Input.tsx
create mode 100644 studio/src/components/ui/Modal.tsx
create mode 100644 studio/src/components/ui/Select.tsx
create mode 100644 studio/src/components/ui/Tabs.tsx
create mode 100644 studio/src/components/ui/Toast.tsx
create mode 100644 studio/src/components/ui/Toggle.tsx
create mode 100644 studio/src/components/ui/UsageIndicator.tsx
create mode 100644 studio/src/components/ui/index.ts
create mode 100644 studio/src/main.tsx
create mode 100644 studio/src/pages/APIPage.tsx
create mode 100644 studio/src/pages/AnalyticsPage.tsx
create mode 100644 studio/src/pages/BillingPage.tsx
create mode 100644 studio/src/pages/DataPage.tsx
create mode 100644 studio/src/pages/DesignPage.preview.css
create mode 100644 studio/src/pages/DesignPage.tsx
create mode 100644 studio/src/pages/DomainPage.tsx
create mode 100644 studio/src/pages/EngagementPage.tsx
create mode 100644 studio/src/pages/GeneralPage.tsx
create mode 100644 studio/src/pages/HomePage.tsx
create mode 100644 studio/src/pages/MonetizationPage.tsx
create mode 100644 studio/src/pages/PluginsPage.tsx
create mode 100644 studio/src/pages/PostEditorPage.tsx
create mode 100644 studio/src/pages/PostsPage.tsx
create mode 100644 studio/src/pages/index.ts
create mode 100644 studio/src/stores/analytics.ts
create mode 100644 studio/src/stores/apiKeys.ts
create mode 100644 studio/src/stores/app.ts
create mode 100644 studio/src/stores/assets.ts
create mode 100644 studio/src/stores/billing.ts
create mode 100644 studio/src/stores/editor.ts
create mode 100644 studio/src/stores/fetcher.ts
create mode 100644 studio/src/stores/hooks.ts
create mode 100644 studio/src/stores/index.ts
create mode 100644 studio/src/stores/interactions.ts
create mode 100644 studio/src/stores/plugins.ts
create mode 100644 studio/src/stores/posts.ts
create mode 100644 studio/src/stores/router.ts
create mode 100644 studio/src/stores/secrets.ts
create mode 100644 studio/src/stores/settings.ts
create mode 100644 studio/src/stores/webhooks.ts
create mode 100644 studio/src/types.ts
create mode 100644 studio/tsconfig.json
create mode 100644 studio/uno.config.ts
create mode 100644 studio/vite.config.ts
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(`
+
+