init
24
.air.toml
Normal file
|
|
@ -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
|
||||||
79
.env.example
Normal file
|
|
@ -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
|
||||||
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
writekit
|
||||||
|
writekit.exe
|
||||||
|
studio/dist/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
tmp
|
||||||
43
.woodpecker.yml
Normal file
|
|
@ -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"
|
||||||
22
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
121
MONETIZATION.md
Normal file
|
|
@ -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 |
|
||||||
22
Makefile
Normal file
|
|
@ -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 .
|
||||||
61
README.md
Normal file
|
|
@ -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 |
|
||||||
119
docker-compose.yml
Normal file
|
|
@ -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:
|
||||||
67
go.mod
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
160
go.sum
Normal file
|
|
@ -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=
|
||||||
54
internal/auth/middleware.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
535
internal/auth/oauth.go
Normal file
|
|
@ -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 ""
|
||||||
|
}
|
||||||
234
internal/billing/lemon.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
126
internal/billing/payout.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
333
internal/billing/store.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
303
internal/billing/webhook.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
198
internal/billing/wise.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
15
internal/build/assets/assets.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
778
internal/build/assets/css/style.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
internal/build/assets/js/main.js
Normal file
|
|
@ -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 = '<div class="search-no-results">No results found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.innerHTML = data.map(r => `
|
||||||
|
<div class="search-result">
|
||||||
|
<a href="${r.url}">
|
||||||
|
<div class="search-result-title">${highlight(r.title, query)}</div>
|
||||||
|
${r.description ? `<div class="search-result-snippet">${highlight(r.description, query)}</div>` : ''}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
results.innerHTML = '<div class="search-no-results">Search failed</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'), '<mark>$1</mark>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
197
internal/build/assets/js/post.js
Normal file
|
|
@ -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 = `
|
||||||
|
<button class="reaction-btn ${active ? 'active' : ''}" data-emoji="${emoji}">
|
||||||
|
<span class="emoji">${emoji}</span>
|
||||||
|
<span class="count">${count}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
container.innerHTML = config.reactionEmojis.map(emoji => {
|
||||||
|
const count = counts[emoji] || 0;
|
||||||
|
const active = userReactions.includes(emoji);
|
||||||
|
return `
|
||||||
|
<button class="reaction-btn ${active ? 'active' : ''}" data-emoji="${emoji}">
|
||||||
|
<span class="emoji">${emoji}</span>
|
||||||
|
<span class="count">${count}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).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 = '<p class="no-comments">No comments yet. Be the first!</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
formContainer.innerHTML = `
|
||||||
|
<form class="comment-form">
|
||||||
|
<textarea placeholder="Write a comment..." rows="3" required></textarea>
|
||||||
|
<button type="submit">Post Comment</button>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
formContainer.querySelector('form').addEventListener('submit', submitComment);
|
||||||
|
} else {
|
||||||
|
formContainer.innerHTML = `
|
||||||
|
<div class="auth-prompt">
|
||||||
|
<a href="/api/reader/login/github?redirect=${encodeURIComponent(window.location.pathname)}">Sign in</a> to leave a comment
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComment(comment) {
|
||||||
|
const date = new Date(comment.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="comment" data-id="${comment.id}">
|
||||||
|
<div class="comment-header">
|
||||||
|
${comment.avatar_url ? `<img src="${comment.avatar_url}" alt="" class="comment-avatar">` : ''}
|
||||||
|
<span class="comment-author">${escapeHtml(comment.name || 'Anonymous')}</span>
|
||||||
|
<span class="comment-date">${date}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">${comment.content_html || escapeHtml(comment.content)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
76
internal/build/templates/base.html
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<meta name="description" content="{{.Description}}">
|
||||||
|
<link rel="canonical" href="{{.CanonicalURL}}">
|
||||||
|
|
||||||
|
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="{{.Title}}">
|
||||||
|
<meta property="og:description" content="{{.Description}}">
|
||||||
|
<meta property="og:type" content="{{.OGType}}">
|
||||||
|
<meta property="og:url" content="{{.CanonicalURL}}">
|
||||||
|
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}">{{end}}
|
||||||
|
<meta property="og:site_name" content="{{.SiteName}}">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="{{.Title}}">
|
||||||
|
<meta name="twitter:description" content="{{.Description}}">
|
||||||
|
{{if .OGImage}}<meta name="twitter:image" content="{{.OGImage}}">{{end}}
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
{{if .FontURL}}<link rel="stylesheet" href="{{.FontURL}}">{{end}}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--accent: {{with index .Settings "accent_color"}}{{.}}{{else}}#2563eb{{end}};
|
||||||
|
--font-body: {{or .FontFamily "system-ui, -apple-system, sans-serif"}};
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{{if .StructuredData}}
|
||||||
|
<script type="application/ld+json">{{.StructuredData}}</script>
|
||||||
|
{{end}}
|
||||||
|
</head>
|
||||||
|
<body class="layout-{{with index .Settings "layout"}}{{.}}{{else}}default{{end}} compactness-{{with index .Settings "compactness"}}{{.}}{{else}}cozy{{end}}">
|
||||||
|
<header class="site-header">
|
||||||
|
<a href="/" class="site-name">{{.SiteName}}</a>
|
||||||
|
<nav class="site-nav">
|
||||||
|
<button type="button" id="search-trigger" class="search-trigger">
|
||||||
|
<span>Search</span>
|
||||||
|
<kbd>/</kbd>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<span>© {{.Year}} {{.SiteName}}</span>
|
||||||
|
{{if .ShowBadge}}<a href="https://writekit.dev" class="powered-by" target="_blank" rel="noopener">Powered by WriteKit</a>{{end}}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<div id="search-modal" class="search-modal">
|
||||||
|
<div class="search-modal-backdrop"></div>
|
||||||
|
<div class="search-modal-content">
|
||||||
|
<input type="text" id="search-input" placeholder="Search..." autocomplete="off">
|
||||||
|
<div id="search-results" class="search-results"></div>
|
||||||
|
<div class="search-hint">Press <kbd>ESC</kbd> to close</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/main.js"></script>
|
||||||
|
{{block "scripts" .}}{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
internal/build/templates/blog.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="blog">
|
||||||
|
<header class="blog-header">
|
||||||
|
<h1>Posts</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="posts-list">
|
||||||
|
{{range .Posts}}
|
||||||
|
<article class="post-card">
|
||||||
|
<a href="/posts/{{.Slug}}">
|
||||||
|
<h2 class="post-card-title">{{.Title}}</h2>
|
||||||
|
<time class="post-card-date" datetime="{{.Date.Format "2006-01-02"}}">{{.Date.Format "January 2, 2006"}}</time>
|
||||||
|
{{if .Description}}
|
||||||
|
<p class="post-card-description">{{.Description}}</p>
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
{{else}}
|
||||||
|
<p class="no-posts">No posts yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if or .PrevPage .NextPage}}
|
||||||
|
<nav class="pagination">
|
||||||
|
{{if .PrevPage}}
|
||||||
|
<a href="{{.PrevPage}}" class="pagination-prev">← Newer</a>
|
||||||
|
{{end}}
|
||||||
|
{{if .NextPage}}
|
||||||
|
<a href="{{.NextPage}}" class="pagination-next">Older →</a>
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
34
internal/build/templates/home.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="home">
|
||||||
|
{{with index .Settings "author_bio"}}
|
||||||
|
<section class="profile">
|
||||||
|
{{with index $.Settings "author_avatar"}}
|
||||||
|
<img src="{{.}}" alt="{{$.SiteName}}" class="profile-avatar">
|
||||||
|
{{end}}
|
||||||
|
<p class="profile-bio">{{.}}</p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class="posts-list">
|
||||||
|
{{range .Posts}}
|
||||||
|
<article class="post-card">
|
||||||
|
<a href="/posts/{{.Slug}}">
|
||||||
|
<h2 class="post-card-title">{{.Title}}</h2>
|
||||||
|
<time class="post-card-date" datetime="{{.Date.Format "2006-01-02"}}">{{.Date.Format "January 2, 2006"}}</time>
|
||||||
|
{{if .Description}}
|
||||||
|
<p class="post-card-description">{{.Description}}</p>
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
{{else}}
|
||||||
|
<p class="no-posts">No posts yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .HasMore}}
|
||||||
|
<nav class="pagination">
|
||||||
|
<a href="/posts" class="view-all">View all posts</a>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
57
internal/build/templates/post.html
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<article class="post">
|
||||||
|
<header class="post-header">
|
||||||
|
<time class="post-date" datetime="{{.Post.Date.Format "2006-01-02"}}">{{.Post.Date.Format "January 2, 2006"}}</time>
|
||||||
|
<h1 class="post-title">{{.Post.Title}}</h1>
|
||||||
|
{{if .Post.Description}}
|
||||||
|
<p class="post-description">{{.Post.Description}}</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Post.Tags}}
|
||||||
|
<div class="post-tags">
|
||||||
|
{{range .Post.Tags}}
|
||||||
|
<a href="/tags/{{.}}" class="tag">#{{.}}</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Post.CoverImage}}
|
||||||
|
<figure class="post-cover">
|
||||||
|
<img src="{{.Post.CoverImage}}" alt="{{.Post.Title}}" loading="eager" />
|
||||||
|
</figure>
|
||||||
|
{{end}}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="post-content prose">
|
||||||
|
{{.ContentHTML}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .InteractionConfig.ReactionsEnabled}}
|
||||||
|
<section id="reactions" class="reactions" data-slug="{{.Post.Slug}}">
|
||||||
|
<div class="reactions-container"></div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .InteractionConfig.CommentsEnabled}}
|
||||||
|
<section id="comments" class="comments" data-slug="{{.Post.Slug}}">
|
||||||
|
<h3 class="comments-title">Comments</h3>
|
||||||
|
<div class="comments-list"></div>
|
||||||
|
<div class="comment-form-container"></div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
{{if or .InteractionConfig.ReactionsEnabled .InteractionConfig.CommentsEnabled}}
|
||||||
|
<script src="/static/js/post.js"></script>
|
||||||
|
<script>
|
||||||
|
WriteKit.init({
|
||||||
|
slug: "{{.Post.Slug}}",
|
||||||
|
reactions: {{.InteractionConfig.ReactionsEnabled}},
|
||||||
|
comments: {{.InteractionConfig.CommentsEnabled}},
|
||||||
|
reactionMode: "{{.InteractionConfig.ReactionMode}}",
|
||||||
|
reactionEmojis: "{{.InteractionConfig.ReactionEmojis}}".split(","),
|
||||||
|
requireAuth: {{.InteractionConfig.ReactionsRequireAuth}}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
139
internal/build/templates/templates.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
451
internal/cloudflare/analytics.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
100
internal/config/tiers.go
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
81
internal/db/db.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
109
internal/db/demos.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
185
internal/db/migrations/001_initial.sql
Normal file
|
|
@ -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;
|
||||||
34
internal/db/models.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
45
internal/db/sessions.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
147
internal/db/tenants.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
62
internal/db/users.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
77
internal/imaginary/client.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
72
internal/markdown/markdown.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
152
internal/markdown/themes.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
185
internal/og/og.go
Normal file
|
|
@ -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
|
||||||
208
internal/server/api.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
703
internal/server/blog.go
Normal file
|
|
@ -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 := `<style>
|
||||||
|
.preview-banner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 99999;
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
@media(min-width:640px) {
|
||||||
|
.preview-banner {
|
||||||
|
bottom: auto;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.preview-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.preview-badge svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.preview-status {
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.preview-link {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: #fff;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.preview-link:hover {
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
.preview-rebuild {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: #18181b;
|
||||||
|
color: #fafafa;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
z-index: 99998;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.preview-rebuild.visible { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
<div class="preview-banner">
|
||||||
|
<span class="preview-badge">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
Preview Mode
|
||||||
|
</span>
|
||||||
|
<span class="preview-status">Viewing as author</span>
|
||||||
|
<a class="preview-link" href="/studio/posts/` + post.Slug + `/edit">Back to Editor</a>
|
||||||
|
</div>
|
||||||
|
<div class="preview-rebuild" id="preview-rebuild">Rebuilding...</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var channel = new BroadcastChannel('writekit-preview');
|
||||||
|
var slug = '` + post.Slug + `';
|
||||||
|
var rebuild = document.getElementById('preview-rebuild');
|
||||||
|
channel.onmessage = function(e) {
|
||||||
|
if (e.data.slug !== slug) return;
|
||||||
|
if (e.data.type === 'rebuilding') {
|
||||||
|
rebuild.classList.add('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.data.type === 'content-update') {
|
||||||
|
var content = document.querySelector('.post-content');
|
||||||
|
if (content) content.innerHTML = e.data.html;
|
||||||
|
var title = document.querySelector('h1');
|
||||||
|
if (title) title.textContent = e.data.title;
|
||||||
|
var desc = document.querySelector('meta[name="description"]');
|
||||||
|
if (desc) desc.content = e.data.description;
|
||||||
|
rebuild.classList.remove('visible');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>`
|
||||||
|
html = bytes.Replace(html, []byte("</body>"), []byte(previewScript+"</body>"), 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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url><loc>` + baseURL + `/</loc></url>
|
||||||
|
`))
|
||||||
|
|
||||||
|
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(" <url><loc>%s/posts/%s</loc><lastmod>%s</lastmod></url>\n",
|
||||||
|
baseURL, p.Slug, lastmod)))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write([]byte("</urlset>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
176
internal/server/build.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
208
internal/server/demo.go
Normal file
|
|
@ -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("</body>"), append([]byte(banner), []byte("</body>")...), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func demoBannerHTML(expiresAt time.Time, redirectURL string) string {
|
||||||
|
expiresUnix := expiresAt.Unix()
|
||||||
|
return `<div id="demo-banner"><div class="demo-inner"><span class="demo-timer"></span><a class="demo-cta" target="_blank" href="/studio">Open Studio</a></div></div>
|
||||||
|
<style>
|
||||||
|
#demo-banner{position:fixed;bottom:0;left:0;right:0;background:rgba(220,38,38,0.95);color:#fff;font-family:system-ui,-apple-system,sans-serif;font-size:13px;z-index:99999;backdrop-filter:blur(8px);padding:10px 16px}
|
||||||
|
@media(min-width:640px){#demo-banner{bottom:auto;top:0;left:auto;right:16px;width:auto;padding:8px 16px}}
|
||||||
|
.demo-inner{display:flex;align-items:center;justify-content:center;gap:12px}
|
||||||
|
.demo-timer{font-variant-numeric:tabular-nums;font-weight:500}
|
||||||
|
.demo-timer.urgent{color:#fecaca;animation:pulse 1s infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
||||||
|
.demo-cta{background:#fff;color:#dc2626;padding:6px 12px;text-decoration:none;font-weight:600;font-size:12px;transition:transform .15s}
|
||||||
|
.demo-cta:hover{transform:scale(1.05)}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const exp=` + strconv.FormatInt(expiresUnix, 10) + `*1000;
|
||||||
|
const timer=document.querySelector('.demo-timer');
|
||||||
|
const cta=document.querySelector('.demo-cta');
|
||||||
|
const update=()=>{
|
||||||
|
const left=Math.max(0,exp-Date.now());
|
||||||
|
const m=Math.floor(left/60000);
|
||||||
|
const s=Math.floor((left%60000)/1000);
|
||||||
|
timer.textContent=m+':'+(s<10?'0':'')+s+' remaining';
|
||||||
|
if(left<30000)timer.classList.add('urgent');
|
||||||
|
if(left<=0){
|
||||||
|
timer.textContent='Demo expired';
|
||||||
|
setTimeout(()=>{
|
||||||
|
const sub=location.hostname.split('.')[0];
|
||||||
|
location.href='` + redirectURL + `?expired=true&subdomain='+sub;
|
||||||
|
},2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
if(location.pathname.startsWith('/studio'))cta.textContent='View Site',cta.href='/posts';
|
||||||
|
})();
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
649
internal/server/platform.go
Normal file
|
|
@ -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(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Dashboard - WriteKit</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p>Create your blog or manage your existing one.</p>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveStaticAssets(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sub, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.StripPrefix("/assets/", http.FileServer(http.FS(sub))).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) checkSubdomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
subdomain := strings.ToLower(r.URL.Query().Get("subdomain"))
|
||||||
|
if subdomain == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "subdomain required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !subdomainRegex.MatchString(subdomain) {
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{
|
||||||
|
"available": false,
|
||||||
|
"reason": "invalid format",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
available, err := s.database.IsSubdomainAvailable(r.Context(), subdomain)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("check subdomain", "error", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{"available": available})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Subdomain string `json:"subdomain"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subdomain := strings.ToLower(req.Subdomain)
|
||||||
|
if !subdomainRegex.MatchString(subdomain) {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid subdomain format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.database.GetTenantByOwner(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("check existing tenant", "error", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
jsonResponse(w, http.StatusConflict, map[string]any{
|
||||||
|
"error": "you already have a blog",
|
||||||
|
"url": s.buildURL(existing.Subdomain),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
available, err := s.database.IsSubdomainAvailable(r.Context(), subdomain)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("check availability", "error", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
jsonError(w, http.StatusConflict, "subdomain not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := s.database.CreateTenant(r.Context(), userID, subdomain)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create tenant", "error", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to create tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.tenantPool.Get(tenant.ID); err != nil {
|
||||||
|
slog.Error("init tenant db", "tenant_id", tenant.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _ := s.database.GetUserByID(r.Context(), userID)
|
||||||
|
if user != nil {
|
||||||
|
if tenantDB, err := s.tenantPool.Get(tenant.ID); err == nil {
|
||||||
|
tenantDB.Exec(`INSERT INTO site_settings (key, value) VALUES ('author_name', ?) ON CONFLICT DO NOTHING`, user.Name)
|
||||||
|
tenantDB.Exec(`INSERT INTO site_settings (key, value) VALUES ('author_avatar', ?) ON CONFLICT DO NOTHING`, user.AvatarURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("tenant created", "subdomain", subdomain, "user_id", userID, "tenant_id", tenant.ID)
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusCreated, map[string]any{
|
||||||
|
"subdomain": subdomain,
|
||||||
|
"url": s.buildURL(subdomain),
|
||||||
|
"tenant_id": tenant.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r)
|
||||||
|
|
||||||
|
tenant, err := s.database.GetTenantByOwner(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("get tenant", "error", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tenant == nil {
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{"has_tenant": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{
|
||||||
|
"has_tenant": true,
|
||||||
|
"tenant": map[string]any{
|
||||||
|
"id": tenant.ID,
|
||||||
|
"subdomain": tenant.Subdomain,
|
||||||
|
"url": s.buildURL(tenant.Subdomain),
|
||||||
|
"premium": tenant.Premium,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonResponse(w http.ResponseWriter, status int, data any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
jsonResponse(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) buildURL(subdomain string) string {
|
||||||
|
scheme := "https"
|
||||||
|
if env := os.Getenv("ENV"); env != "prod" {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s.%s", scheme, subdomain, s.domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createDemo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
referer := r.Header.Get("Referer")
|
||||||
|
|
||||||
|
validOrigins := []string{
|
||||||
|
"https://" + s.domain,
|
||||||
|
"https://www." + s.domain,
|
||||||
|
}
|
||||||
|
if env := os.Getenv("ENV"); env != "prod" {
|
||||||
|
validOrigins = append(validOrigins,
|
||||||
|
"http://"+s.domain,
|
||||||
|
"http://www."+s.domain,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidOrigin := func(o string) bool {
|
||||||
|
for _, v := range validOrigins {
|
||||||
|
if o == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidReferer := func(ref string) bool {
|
||||||
|
for _, v := range validOrigins {
|
||||||
|
if strings.HasPrefix(ref, v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if origin != "" && !isValidOrigin(origin) {
|
||||||
|
jsonError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if origin == "" && referer != "" && !isValidReferer(referer) {
|
||||||
|
jsonError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" {
|
||||||
|
req.Name = "Demo User"
|
||||||
|
}
|
||||||
|
if req.Color == "" {
|
||||||
|
req.Color = "#10b981"
|
||||||
|
}
|
||||||
|
|
||||||
|
demo, err := s.database.CreateDemo(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create demo", "error", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to create demo")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.tenantPool.MarkAsDemo(demo.ID)
|
||||||
|
if _, err := s.tenantPool.Get(demo.ID); err != nil {
|
||||||
|
slog.Error("init demo db", "demo_id", demo.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.seedDemoContent(demo.ID, req.Name, req.Color); err != nil {
|
||||||
|
slog.Error("seed demo content", "demo_id", demo.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("demo created", "subdomain", demo.Subdomain, "demo_id", demo.ID, "expires_at", demo.ExpiresAt)
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusCreated, map[string]any{
|
||||||
|
"subdomain": demo.Subdomain,
|
||||||
|
"url": s.buildURL(demo.Subdomain),
|
||||||
|
"expires_at": demo.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) seedDemoContent(demoID, authorName, accentColor string) error {
|
||||||
|
db, err := s.tenantPool.Get(demoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get tenant pool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := db.Exec(`INSERT INTO site_settings (key, value) VALUES
|
||||||
|
('site_name', ?),
|
||||||
|
('site_description', 'Thoughts on building software, developer tools, and the craft of engineering.'),
|
||||||
|
('accent_color', ?),
|
||||||
|
('author_name', ?),
|
||||||
|
('is_demo', 'true')
|
||||||
|
ON CONFLICT DO NOTHING`, authorName+"'s Blog", accentColor, authorName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert settings: %w", err)
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
slog.Info("seed demo settings", "demo_id", demoID, "rows_affected", rows)
|
||||||
|
|
||||||
|
_, err = db.Exec(`INSERT INTO posts (id, slug, title, description, content_md, tags, cover_image, is_published, published_at, created_at, modified_at) VALUES
|
||||||
|
('demo-post-1', 'shipping-a-side-project',
|
||||||
|
'I Finally Shipped My Side Project',
|
||||||
|
'After mass years of abandoned repos, here is what finally worked.',
|
||||||
|
'I have mass abandoned side projects. We all do. But last month, I actually shipped one.
|
||||||
|
|
||||||
|
Here''s what I did differently this time.
|
||||||
|
|
||||||
|
## The Graveyard
|
||||||
|
|
||||||
|
Before we get to what worked, let me be honest about what didn''t. My GitHub is full of:
|
||||||
|
|
||||||
|
- [ ] A "better" todo app (mass of features planned, mass built)
|
||||||
|
- [ ] A CLI tool I used once
|
||||||
|
- [ ] Three different blog engines (ironic, I know)
|
||||||
|
- [x] This project — finally shipped
|
||||||
|
|
||||||
|
The pattern was always the same: mass enthusiasm for a week, mass silence forever.
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
This time, I set one rule: **ship in two weeks or kill it**.
|
||||||
|
|
||||||
|
> "If you''re not embarrassed by the first version of your product, you''ve launched too late." — Reid Hoffman
|
||||||
|
|
||||||
|
I printed that quote and stuck it on my monitor. Every time I wanted to add "just one more feature," I looked at it.
|
||||||
|
|
||||||
|
## The Stack
|
||||||
|
|
||||||
|
I kept it minimal:
|
||||||
|
|
||||||
|
| Layer | Choice | Why |
|
||||||
|
|-------|--------|-----|
|
||||||
|
| Frontend | React | I know it well |
|
||||||
|
| Backend | Go | Fast, simple deploys |
|
||||||
|
| Database | SQLite | No ops overhead |
|
||||||
|
| Hosting | Fly.io | $0 to start |
|
||||||
|
|
||||||
|
~~I originally planned to use Kubernetes~~ — glad I didn''t. SQLite on a single server handles more traffic than I''ll ever get.
|
||||||
|
|
||||||
|
## The Launch
|
||||||
|
|
||||||
|
I posted on Twitter, mass likes. mass signups. Then... mass complaints about bugs I hadn''t tested for.
|
||||||
|
|
||||||
|
But here''s the thing: **those bugs only exist because I shipped**. I fixed them in a day. If I''d waited for "perfect," I''d still be tweaking CSS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ship something this week. mass needs to be big. Your mass abandoned project could be mass person''s favorite tool.',
|
||||||
|
'["career","shipping"]',
|
||||||
|
'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=1200&h=630&fit=crop',
|
||||||
|
1,
|
||||||
|
datetime('now', '-2 days'),
|
||||||
|
datetime('now', '-2 days'),
|
||||||
|
datetime('now', '-2 days')),
|
||||||
|
|
||||||
|
('demo-post-2', 'debugging-production-like-a-detective',
|
||||||
|
'Debugging Production Like a Detective',
|
||||||
|
'A systematic approach to finding bugs when console.log is not enough.',
|
||||||
|
'Last week, our API started returning 500 errors. Not always — just enough to be infuriating. Here''s how I tracked it down.
|
||||||
|
|
||||||
|
## The Symptoms
|
||||||
|
|
||||||
|
Users reported "random failures." The logs showed:
|
||||||
|
|
||||||
|
` + "```" + `
|
||||||
|
error: connection refused
|
||||||
|
error: connection refused
|
||||||
|
error: context deadline exceeded
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
Helpful, right?
|
||||||
|
|
||||||
|
## Step 1: Gather Evidence
|
||||||
|
|
||||||
|
First, I needed data. I added structured logging:
|
||||||
|
|
||||||
|
` + "```go" + `
|
||||||
|
slog.Error("request failed",
|
||||||
|
"endpoint", r.URL.Path,
|
||||||
|
"user_id", userID,
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
Within an hour, a pattern emerged:
|
||||||
|
|
||||||
|
| Time | Endpoint | Duration | Result |
|
||||||
|
|------|----------|----------|--------|
|
||||||
|
| 14:01 | /api/posts | 45ms | OK |
|
||||||
|
| 14:01 | /api/posts | 52ms | OK |
|
||||||
|
| 14:02 | /api/posts | 30,004ms | FAIL |
|
||||||
|
| 14:02 | /api/users | 48ms | OK |
|
||||||
|
|
||||||
|
The failures were always *exactly* 30 seconds — our timeout value.
|
||||||
|
|
||||||
|
## Step 2: Form a Hypothesis
|
||||||
|
|
||||||
|
> The 30-second timeout suggested a connection hanging, not failing fast.
|
||||||
|
|
||||||
|
I suspected connection pool exhaustion. Our pool was set to 10 connections. Under load, requests would wait for a free connection, then timeout.
|
||||||
|
|
||||||
|
## Step 3: Test the Theory
|
||||||
|
|
||||||
|
I checked the pool stats:
|
||||||
|
|
||||||
|
` + "```sql" + `
|
||||||
|
SELECT count(*) FROM pg_stat_activity
|
||||||
|
WHERE application_name = ''myapp'';
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
Result: **10**. Exactly at the limit.
|
||||||
|
|
||||||
|
## The Fix
|
||||||
|
|
||||||
|
` + "```go" + `
|
||||||
|
db.SetMaxOpenConns(25) // was: 10
|
||||||
|
db.SetMaxIdleConns(10) // was: 2
|
||||||
|
db.SetConnMaxLifetime(time.Hour)
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
Three lines. Two days of debugging. Zero more timeouts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The lesson: **when debugging production, be a detective, not a guesser**. Gather evidence, form hypotheses, test them.',
|
||||||
|
'["debugging","golang","databases"]',
|
||||||
|
'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&h=630&fit=crop',
|
||||||
|
1,
|
||||||
|
datetime('now', '-5 days'),
|
||||||
|
datetime('now', '-5 days'),
|
||||||
|
datetime('now', '-5 days')),
|
||||||
|
|
||||||
|
('demo-post-3', 'sqlite-in-production',
|
||||||
|
'Yes, You Can Use SQLite in Production',
|
||||||
|
'How to scale SQLite further than you think — and when to finally migrate.',
|
||||||
|
'Every time I mention SQLite in production, someone says "that doesn''t scale." Let me share some numbers.
|
||||||
|
|
||||||
|
## The Reality
|
||||||
|
|
||||||
|
SQLite handles:
|
||||||
|
|
||||||
|
- **Millions** of reads per second
|
||||||
|
- **Thousands** of writes per second (with WAL mode)
|
||||||
|
- Databases up to **281 TB** (theoretical limit)
|
||||||
|
|
||||||
|
For context, that''s more than most apps will ever need.
|
||||||
|
|
||||||
|
## Configuration That Matters
|
||||||
|
|
||||||
|
The defaults are conservative. For production, I use:
|
||||||
|
|
||||||
|
` + "```sql" + `
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
PRAGMA cache_size = -64000; -- 64MB cache
|
||||||
|
PRAGMA busy_timeout = 5000;
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
This gives you:
|
||||||
|
|
||||||
|
| Setting | Default | Production | Impact |
|
||||||
|
|---------|---------|------------|--------|
|
||||||
|
| journal_mode | DELETE | WAL | Concurrent reads during writes |
|
||||||
|
| synchronous | FULL | NORMAL | 10x faster writes, still safe |
|
||||||
|
| cache_size | -2000 | -64000 | Fewer disk reads |
|
||||||
|
|
||||||
|
## When to Migrate
|
||||||
|
|
||||||
|
SQLite is **not** right when you need:
|
||||||
|
|
||||||
|
1. Multiple servers writing to the same database
|
||||||
|
2. ~~Horizontal scaling~~ (though read replicas now exist via Litestream)
|
||||||
|
3. Sub-millisecond writes under heavy contention
|
||||||
|
|
||||||
|
If you''re running a single server — which is most apps — SQLite is great.
|
||||||
|
|
||||||
|
> "I''ve never used a database that made me this happy." — every SQLite user, probably
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Here''s my standard setup:
|
||||||
|
|
||||||
|
` + "```go" + `
|
||||||
|
db, err := sql.Open("sqlite3", "file:app.db?_journal=WAL&_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1) // SQLite writes are serialized anyway
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Stop overengineering. Start with SQLite. Migrate when you *actually* hit limits — you probably never will.',
|
||||||
|
'["sqlite","databases","architecture"]',
|
||||||
|
'https://images.unsplash.com/photo-1544383835-bda2bc66a55d?w=1200&h=630&fit=crop',
|
||||||
|
1,
|
||||||
|
datetime('now', '-8 days'),
|
||||||
|
datetime('now', '-8 days'),
|
||||||
|
datetime('now', '-8 days')),
|
||||||
|
|
||||||
|
('demo-post-4', 'my-2024-reading-list',
|
||||||
|
'My 2024 Reading List',
|
||||||
|
'',
|
||||||
|
'Here are the books that shaped my thinking this year.
|
||||||
|
|
||||||
|
## Technical Books
|
||||||
|
|
||||||
|
- **Designing Data-Intensive Applications** by Martin Kleppmann
|
||||||
|
- **The Pragmatic Programmer** by David Thomas
|
||||||
|
- **Staff Engineer** by Will Larson
|
||||||
|
|
||||||
|
## Non-Technical
|
||||||
|
|
||||||
|
- **The Mom Test** by Rob Fitzpatrick
|
||||||
|
- **Building a Second Brain** by Tiago Forte
|
||||||
|
|
||||||
|
More thoughts on each coming soon...',
|
||||||
|
'["books","learning"]',
|
||||||
|
'',
|
||||||
|
1,
|
||||||
|
datetime('now', '-12 days'),
|
||||||
|
datetime('now', '-12 days'),
|
||||||
|
datetime('now', '-12 days')),
|
||||||
|
|
||||||
|
('demo-draft-1', 'understanding-react-server-components',
|
||||||
|
'Understanding React Server Components',
|
||||||
|
'A deep dive into RSC architecture and when to use them.',
|
||||||
|
'React Server Components are confusing. Let me try to explain them.
|
||||||
|
|
||||||
|
## The Problem RSC Solves
|
||||||
|
|
||||||
|
Traditional React apps ship a lot of JavaScript to the browser. RSC lets you run components on the server and send only the HTML.
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
TODO: Add diagrams
|
||||||
|
|
||||||
|
## When to Use RSC
|
||||||
|
|
||||||
|
- Data fetching
|
||||||
|
- Heavy dependencies
|
||||||
|
- SEO-critical pages
|
||||||
|
|
||||||
|
## When NOT to Use RSC
|
||||||
|
|
||||||
|
- Interactive components
|
||||||
|
- Client state
|
||||||
|
- Event handlers
|
||||||
|
|
||||||
|
Still writing this one...',
|
||||||
|
'["react","architecture"]',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
datetime('now', '-1 days'),
|
||||||
|
datetime('now', '-2 hours')),
|
||||||
|
|
||||||
|
('demo-draft-2', 'vim-tricks-i-use-daily',
|
||||||
|
'Vim Tricks I Use Daily',
|
||||||
|
'The shortcuts that actually stuck after 5 years of using Vim.',
|
||||||
|
'After mass years of Vim, these are the commands I use daily.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
- Ctrl+d and Ctrl+u for half page down/up
|
||||||
|
- zz to center current line
|
||||||
|
|
||||||
|
## Editing
|
||||||
|
|
||||||
|
Still collecting my favorites...',
|
||||||
|
'["vim","productivity"]',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
datetime('now', '-3 days'),
|
||||||
|
datetime('now', '-1 days'))
|
||||||
|
ON CONFLICT DO NOTHING`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert posts: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("seed demo posts", "demo_id", demoID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ensureDemoSeeded(demoID string) {
|
||||||
|
db, err := s.tenantPool.Get(demoID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("ensureDemoSeeded: get pool", "demo_id", demoID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("ensureDemoSeeded: count posts", "demo_id", demoID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("re-seeding demo content", "demo_id", demoID)
|
||||||
|
if err := s.seedDemoContent(demoID, "Demo User", "#10b981"); err != nil {
|
||||||
|
slog.Error("ensureDemoSeeded: seed failed", "demo_id", demoID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
126
internal/server/ratelimit.go
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/writekitapp/writekit/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bucket struct {
|
||||||
|
tokens float64
|
||||||
|
lastFill time.Time
|
||||||
|
rateLimit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
buckets map[string]*bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRateLimiter() *RateLimiter {
|
||||||
|
rl := &RateLimiter{
|
||||||
|
buckets: make(map[string]*bucket),
|
||||||
|
}
|
||||||
|
go rl.cleanup()
|
||||||
|
return rl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) Allow(tenantID string, limit int) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
b, ok := rl.buckets[tenantID]
|
||||||
|
if !ok {
|
||||||
|
b = &bucket{
|
||||||
|
tokens: float64(limit),
|
||||||
|
lastFill: now,
|
||||||
|
rateLimit: limit,
|
||||||
|
}
|
||||||
|
rl.buckets[tenantID] = b
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.rateLimit != limit {
|
||||||
|
b.rateLimit = limit
|
||||||
|
b.tokens = float64(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(b.lastFill)
|
||||||
|
tokensToAdd := elapsed.Hours() * float64(limit)
|
||||||
|
b.tokens = min(b.tokens+tokensToAdd, float64(limit))
|
||||||
|
b.lastFill = now
|
||||||
|
|
||||||
|
if b.tokens >= 1 {
|
||||||
|
b.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) cleanup() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
for range ticker.C {
|
||||||
|
rl.mu.Lock()
|
||||||
|
threshold := time.Now().Add(-1 * time.Hour)
|
||||||
|
for k, b := range rl.buckets {
|
||||||
|
if b.lastFill.Before(threshold) {
|
||||||
|
delete(rl.buckets, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) apiRateLimitMiddleware(rl *RateLimiter) func(http.Handler) http.Handler {
|
||||||
|
return func(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 == "" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.database.GetTenantByID(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
premium := t != nil && t.Premium
|
||||||
|
tierInfo := config.GetTierInfo(premium)
|
||||||
|
limit := tierInfo.Config.APIRateLimit
|
||||||
|
|
||||||
|
if !rl.Allow(tenantID, limit) {
|
||||||
|
w.Header().Set("X-RateLimit-Limit", itoa(limit))
|
||||||
|
w.Header().Set("X-RateLimit-Reset", "3600")
|
||||||
|
w.Header().Set("Retry-After", "60")
|
||||||
|
jsonError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("X-RateLimit-Limit", itoa(limit))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
s := ""
|
||||||
|
for n > 0 {
|
||||||
|
s = string(rune('0'+n%10)) + s
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b float64) float64 {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
634
internal/server/reader.go
Normal file
|
|
@ -0,0 +1,634 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/writekitapp/writekit/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) readerRoutes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Get("/login/{provider}", s.readerLogin)
|
||||||
|
r.Get("/auth/callback", s.readerAuthCallback)
|
||||||
|
r.Get("/auth/providers", s.readerAuthProviders)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.readerAuthMiddleware)
|
||||||
|
|
||||||
|
r.Get("/config", s.getInteractionConfig)
|
||||||
|
|
||||||
|
r.Get("/posts/{slug}/comments", s.listComments)
|
||||||
|
r.Post("/posts/{slug}/comments", s.createComment)
|
||||||
|
r.Delete("/comments/{id}", s.deleteComment)
|
||||||
|
|
||||||
|
r.Get("/posts/{slug}/reactions", s.getReactions)
|
||||||
|
r.Post("/posts/{slug}/reactions", s.toggleReaction)
|
||||||
|
|
||||||
|
r.Get("/me", s.getReaderMe)
|
||||||
|
r.Post("/logout", s.readerLogout)
|
||||||
|
|
||||||
|
r.Get("/search", s.search)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getInteractionConfig(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)
|
||||||
|
config := q.GetInteractionConfig(r.Context())
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listComments(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)
|
||||||
|
comments, err := q.ListComments(r.Context(), slug)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to list comments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createComment(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)
|
||||||
|
|
||||||
|
if q.GetSettingWithDefault(r.Context(), "comments_enabled") != "true" {
|
||||||
|
jsonError(w, http.StatusForbidden, "comments disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ParentID *int64 `json:"parent_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Content == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "content required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getReaderUserID(r)
|
||||||
|
if userID == "" {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "login required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info for validation
|
||||||
|
user, _ := q.GetUserByID(r.Context(), userID)
|
||||||
|
authorName := "Anonymous"
|
||||||
|
authorEmail := ""
|
||||||
|
if user != nil {
|
||||||
|
authorName = user.Name
|
||||||
|
authorEmail = user.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate comment via plugins
|
||||||
|
runner := s.getPluginRunner(tenantID, db)
|
||||||
|
allowed, reason, _ := runner.TriggerValidation(r.Context(), "comment.validate", map[string]any{
|
||||||
|
"content": req.Content,
|
||||||
|
"authorName": authorName,
|
||||||
|
"authorEmail": authorEmail,
|
||||||
|
"postSlug": slug,
|
||||||
|
})
|
||||||
|
runner.Close()
|
||||||
|
|
||||||
|
if !allowed {
|
||||||
|
msg := "Comment rejected"
|
||||||
|
if reason != "" {
|
||||||
|
msg = reason
|
||||||
|
}
|
||||||
|
jsonError(w, http.StatusForbidden, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := &tenant.Comment{
|
||||||
|
UserID: userID,
|
||||||
|
PostSlug: slug,
|
||||||
|
Content: req.Content,
|
||||||
|
ParentID: req.ParentID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.CreateComment(r.Context(), comment); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to create comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger comment.created hook
|
||||||
|
go func() {
|
||||||
|
runner := s.getPluginRunner(tenantID, db)
|
||||||
|
defer runner.Close()
|
||||||
|
|
||||||
|
// Get post info
|
||||||
|
post, _ := q.GetPost(r.Context(), slug)
|
||||||
|
postData := map[string]any{"slug": slug, "title": slug, "url": "/" + slug}
|
||||||
|
if post != nil {
|
||||||
|
postData["title"] = post.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
runner.TriggerHook(r.Context(), "comment.created", map[string]any{
|
||||||
|
"comment": map[string]any{
|
||||||
|
"id": comment.ID,
|
||||||
|
"content": comment.Content,
|
||||||
|
"authorName": authorName,
|
||||||
|
"authorEmail": authorEmail,
|
||||||
|
"postSlug": slug,
|
||||||
|
"createdAt": comment.CreatedAt.Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
"post": postData,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusCreated, comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID := r.Context().Value(tenantIDKey).(string)
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
userID := getReaderUserID(r)
|
||||||
|
if userID == "" {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "login required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.tenantPool.Get(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := tenant.NewQueries(db)
|
||||||
|
|
||||||
|
var commentID int64
|
||||||
|
if _, err := json.Number(id).Int64(); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid comment id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commentID, _ = json.Number(id).Int64()
|
||||||
|
|
||||||
|
comment, err := q.GetComment(r.Context(), commentID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to get comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if comment == nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "comment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if comment.UserID != userID {
|
||||||
|
jsonError(w, http.StatusForbidden, "not your comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.DeleteComment(r.Context(), commentID); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to delete comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getReactions(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)
|
||||||
|
counts, err := q.GetReactionCounts(r.Context(), slug)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to get reactions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userReactions []string
|
||||||
|
userID := getReaderUserID(r)
|
||||||
|
if userID != "" {
|
||||||
|
userReactions, _ = q.GetUserReactions(r.Context(), userID, slug)
|
||||||
|
} else if anonID := getAnonID(r); anonID != "" {
|
||||||
|
userReactions, _ = q.GetAnonReactions(r.Context(), anonID, slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{
|
||||||
|
"counts": counts,
|
||||||
|
"user": userReactions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) toggleReaction(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)
|
||||||
|
|
||||||
|
if q.GetSettingWithDefault(r.Context(), "reactions_enabled") != "true" {
|
||||||
|
jsonError(w, http.StatusForbidden, "reactions disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Emoji == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "emoji required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getReaderUserID(r)
|
||||||
|
anonID := ""
|
||||||
|
requireAuth := q.GetSettingWithDefault(r.Context(), "reactions_require_auth") == "true"
|
||||||
|
|
||||||
|
if userID == "" {
|
||||||
|
if requireAuth {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "login required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
anonID = getOrCreateAnonID(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
added, err := q.ToggleReaction(r.Context(), userID, anonID, slug, req.Emoji)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to toggle reaction")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]bool{"added": added})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID := r.Context().Value(tenantIDKey).(string)
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
jsonResponse(w, http.StatusOK, []any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.tenantPool.Get(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := tenant.NewQueries(db)
|
||||||
|
results, err := q.Search(r.Context(), query, 20)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusOK, []any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getReaderMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := getReaderUserID(r)
|
||||||
|
if userID == "" {
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{"logged_in": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
user, err := q.GetUserByID(r.Context(), userID)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{"logged_in": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{
|
||||||
|
"logged_in": true,
|
||||||
|
"user": map[string]any{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"name": user.Name,
|
||||||
|
"avatar_url": user.AvatarURL,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) readerLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := extractReaderToken(r)
|
||||||
|
if token == "" {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID := r.Context().Value(tenantIDKey).(string)
|
||||||
|
db, err := s.tenantPool.Get(tenantID)
|
||||||
|
if err == nil {
|
||||||
|
q := tenant.NewQueries(db)
|
||||||
|
q.DeleteSession(r.Context(), token)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "reader_session",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getReaderUserID(r *http.Request) string {
|
||||||
|
if id, ok := r.Context().Value(readerUserIDKey).(string); ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type readerCtxKey string
|
||||||
|
|
||||||
|
const readerUserIDKey readerCtxKey = "readerUserID"
|
||||||
|
|
||||||
|
func (s *Server) readerAuthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := extractReaderToken(r)
|
||||||
|
if token == "" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID, ok := r.Context().Value(tenantIDKey).(string)
|
||||||
|
if !ok || tenantID == "" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.tenantPool.Get(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := tenant.NewQueries(db)
|
||||||
|
session, err := q.ValidateSession(r.Context(), token)
|
||||||
|
if err != nil || session == nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), readerUserIDKey, session.UserID)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractReaderToken(r *http.Request) string {
|
||||||
|
if cookie, err := r.Cookie("reader_session"); err == nil && cookie.Value != "" {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
return strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) readerLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
provider := chi.URLParam(r, "provider")
|
||||||
|
if provider != "google" && provider != "github" && provider != "discord" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid provider")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID := r.Context().Value(tenantIDKey).(string)
|
||||||
|
redirect := r.URL.Query().Get("redirect")
|
||||||
|
|
||||||
|
baseURL := os.Getenv("BASE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://writekit.dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantInfo, err := s.database.GetTenantByID(r.Context(), tenantID)
|
||||||
|
if err != nil || tenantInfo == nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := tenantInfo.CustomDomain
|
||||||
|
if domain == "" {
|
||||||
|
domain = tenantInfo.Subdomain + "." + s.domain
|
||||||
|
}
|
||||||
|
callbackURL := fmt.Sprintf("https://%s/api/reader/auth/callback", domain)
|
||||||
|
if redirect != "" {
|
||||||
|
callbackURL += "?redirect=" + redirect
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := fmt.Sprintf("%s/auth/%s?tenant=%s&callback=%s",
|
||||||
|
baseURL, provider, tenantID, callbackURL)
|
||||||
|
|
||||||
|
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) readerAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
redirect := r.URL.Query().Get("redirect")
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "missing token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID := r.Context().Value(tenantIDKey).(string)
|
||||||
|
|
||||||
|
baseURL := os.Getenv("BASE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://writekit.dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
userURL := baseURL + "/auth/user?token=" + token
|
||||||
|
resp, err := http.Get(userURL)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "auth failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
jsonError(w, http.StatusUnauthorized, "invalid token: "+string(body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var platformUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&platformUser); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to parse user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.tenantPool.Get(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := tenant.NewQueries(db)
|
||||||
|
|
||||||
|
user, err := q.GetUserByID(r.Context(), platformUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
user = &tenant.User{
|
||||||
|
ID: platformUser.ID,
|
||||||
|
Email: platformUser.Email,
|
||||||
|
Name: platformUser.Name,
|
||||||
|
AvatarURL: platformUser.AvatarURL,
|
||||||
|
}
|
||||||
|
if err := q.CreateUser(r.Context(), user); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := q.CreateSession(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantInfo, _ := s.database.GetTenantByID(r.Context(), tenantID)
|
||||||
|
secure := tenantInfo != nil && !strings.HasPrefix(tenantInfo.Subdomain, "localhost")
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "reader_session",
|
||||||
|
Value: session.Token,
|
||||||
|
Path: "/",
|
||||||
|
Expires: session.ExpiresAt,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
if redirect == "" || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
|
||||||
|
redirect = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) readerAuthProviders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
baseURL := os.Getenv("BASE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://writekit.dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get(baseURL + "/auth/providers")
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{"providers": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{"providers": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrCreateAnonID(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
if cookie, err := r.Cookie("anon_id"); err == nil && cookie.Value != "" {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
anonID := hex.EncodeToString(b)
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "anon_id",
|
||||||
|
Value: anonID,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 365 * 24 * 60 * 60,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return anonID
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAnonID(r *http.Request) string {
|
||||||
|
if cookie, err := r.Cookie("anon_id"); err == nil {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
163
internal/server/server.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/writekitapp/writekit/internal/auth"
|
||||||
|
"github.com/writekitapp/writekit/internal/cloudflare"
|
||||||
|
"github.com/writekitapp/writekit/internal/db"
|
||||||
|
"github.com/writekitapp/writekit/internal/imaginary"
|
||||||
|
"github.com/writekitapp/writekit/internal/storage"
|
||||||
|
"github.com/writekitapp/writekit/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
router chi.Router
|
||||||
|
database *db.DB
|
||||||
|
tenantPool *tenant.Pool
|
||||||
|
tenantCache *tenant.Cache
|
||||||
|
storage storage.Client
|
||||||
|
imaginary *imaginary.Client
|
||||||
|
cloudflare *cloudflare.Client
|
||||||
|
rateLimiter *RateLimiter
|
||||||
|
domain string
|
||||||
|
jarvisURL string
|
||||||
|
stopCleanup chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(database *db.DB, pool *tenant.Pool, cache *tenant.Cache, storageClient storage.Client) *Server {
|
||||||
|
domain := os.Getenv("DOMAIN")
|
||||||
|
if domain == "" {
|
||||||
|
domain = "writekit.dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
jarvisURL := os.Getenv("JARVIS_URL")
|
||||||
|
if jarvisURL == "" {
|
||||||
|
jarvisURL = "http://localhost:8090"
|
||||||
|
}
|
||||||
|
|
||||||
|
var imgClient *imaginary.Client
|
||||||
|
if url := os.Getenv("IMAGINARY_URL"); url != "" {
|
||||||
|
imgClient = imaginary.New(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfClient := cloudflare.NewClient()
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
router: chi.NewRouter(),
|
||||||
|
database: database,
|
||||||
|
tenantPool: pool,
|
||||||
|
tenantCache: cache,
|
||||||
|
storage: storageClient,
|
||||||
|
imaginary: imgClient,
|
||||||
|
cloudflare: cfClient,
|
||||||
|
rateLimiter: NewRateLimiter(),
|
||||||
|
domain: domain,
|
||||||
|
jarvisURL: jarvisURL,
|
||||||
|
stopCleanup: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.router.Use(middleware.Logger)
|
||||||
|
s.router.Use(middleware.Recoverer)
|
||||||
|
s.router.Use(middleware.Compress(5))
|
||||||
|
|
||||||
|
s.routes()
|
||||||
|
go s.cleanupDemos()
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.router.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) routes() {
|
||||||
|
s.router.HandleFunc("/*", s.route)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) route(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host := r.Host
|
||||||
|
if idx := strings.Index(host, ":"); idx != -1 {
|
||||||
|
host = host[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == s.domain || host == "www."+s.domain {
|
||||||
|
s.servePlatform(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(host, "."+s.domain) {
|
||||||
|
subdomain := strings.TrimSuffix(host, "."+s.domain)
|
||||||
|
s.serveBlog(w, r, subdomain)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.notFound(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) servePlatform(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mux := chi.NewRouter()
|
||||||
|
mux.NotFound(s.notFound)
|
||||||
|
|
||||||
|
mux.Get("/", s.platformHome)
|
||||||
|
mux.Get("/login", s.platformLogin)
|
||||||
|
mux.Get("/signup", s.platformSignup)
|
||||||
|
mux.Get("/signup/complete", s.platformSignup)
|
||||||
|
mux.Get("/dashboard", s.platformDashboard)
|
||||||
|
mux.Handle("/assets/*", http.HandlerFunc(s.serveStaticAssets))
|
||||||
|
|
||||||
|
mux.Mount("/auth", auth.NewHandler(s.database).Routes())
|
||||||
|
|
||||||
|
mux.Route("/api", func(r chi.Router) {
|
||||||
|
r.Get("/tenant/check", s.checkSubdomain)
|
||||||
|
r.Post("/demo", s.createDemo)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(auth.SessionMiddleware(s.database))
|
||||||
|
r.Post("/tenant", s.createTenant)
|
||||||
|
r.Get("/tenant", s.getTenant)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) cleanupDemos() {
|
||||||
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
expired, err := s.database.CleanupExpiredDemos(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("cleanup expired demos", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, d := range expired {
|
||||||
|
s.tenantPool.Evict(d.ID)
|
||||||
|
s.tenantCache.Delete(d.Subdomain)
|
||||||
|
slog.Info("cleaned up expired demo", "demo_id", d.ID, "subdomain", d.Subdomain)
|
||||||
|
}
|
||||||
|
case <-s.stopCleanup:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Close() {
|
||||||
|
close(s.stopCleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPluginRunner returns a PluginRunner for the given tenant
|
||||||
|
func (s *Server) getPluginRunner(tenantID string, db *sql.DB) *tenant.PluginRunner {
|
||||||
|
return tenant.NewPluginRunner(db, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
BIN
internal/server/static/analytics-screenshot.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
internal/server/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
internal/server/static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
internal/server/static/favicon-192x192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
internal/server/static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
internal/server/static/writekit-icon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
internal/server/static/writekit-icon.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
24
internal/server/static/writekit-icon.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="writekit-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#10b981"/>
|
||||||
|
<stop offset="100%" style="stop-color:#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background circle -->
|
||||||
|
<circle cx="256" cy="256" r="256" fill="url(#writekit-gradient)"/>
|
||||||
|
|
||||||
|
<!-- Open book icon (scaled and centered) -->
|
||||||
|
<g transform="translate(96, 96) scale(13.33)">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 961 B |
2052
internal/server/studio.go
Normal file
113
internal/server/sync.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/writekitapp/writekit/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) StartAnalyticsSync() {
|
||||||
|
if !s.cloudflare.IsConfigured() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.runAnalyticsSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runAnalyticsSync() {
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
s.syncYesterdayAnalytics()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
s.syncYesterdayAnalytics()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) syncYesterdayAnalytics() {
|
||||||
|
ctx := context.Background()
|
||||||
|
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
|
||||||
|
tenants, err := s.database.ListTenants(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("analytics sync: list tenants: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tenants {
|
||||||
|
s.syncTenantAnalytics(ctx, t.ID, t.Subdomain, yesterday)
|
||||||
|
}
|
||||||
|
|
||||||
|
demos, err := s.database.ListActiveDemos(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("analytics sync: list demos: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range demos {
|
||||||
|
s.syncTenantAnalytics(ctx, d.ID, d.Subdomain, yesterday)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) syncTenantAnalytics(ctx context.Context, tenantID, subdomain, date string) {
|
||||||
|
hostname := subdomain + "." + s.domain
|
||||||
|
|
||||||
|
tenantDB, err := s.tenantPool.Get(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("analytics sync: get tenant db %s: %v", tenantID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := tenant.NewQueries(tenantDB)
|
||||||
|
|
||||||
|
has, err := q.HasArchivedDate(ctx, date)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("analytics sync: check archived %s: %v", tenantID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfData, err := s.cloudflare.GetAnalytics(ctx, 1, hostname)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("analytics sync: fetch cf data %s: %v", tenantID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfData == nil || len(cfData.Daily) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
day := cfData.Daily[0]
|
||||||
|
archived := &tenant.ArchivedDay{
|
||||||
|
Date: day.Date,
|
||||||
|
Requests: day.Requests,
|
||||||
|
PageViews: day.PageViews,
|
||||||
|
UniqueVisitors: day.Visitors,
|
||||||
|
Bandwidth: day.Bandwidth,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range cfData.Browsers {
|
||||||
|
archived.Browsers = append(archived.Browsers, tenant.NamedStat{Name: b.Name, Count: b.Count})
|
||||||
|
}
|
||||||
|
for _, o := range cfData.OS {
|
||||||
|
archived.OS = append(archived.OS, tenant.NamedStat{Name: o.Name, Count: o.Count})
|
||||||
|
}
|
||||||
|
for _, d := range cfData.Devices {
|
||||||
|
archived.Devices = append(archived.Devices, tenant.NamedStat{Name: d.Name, Count: d.Count})
|
||||||
|
}
|
||||||
|
for _, c := range cfData.Countries {
|
||||||
|
archived.Countries = append(archived.Countries, tenant.NamedStat{Name: c.Name, Count: c.Count})
|
||||||
|
}
|
||||||
|
for _, p := range cfData.Paths {
|
||||||
|
archived.Paths = append(archived.Paths, tenant.PageStats{Path: p.Path, Views: p.Requests})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.SaveDailyAnalytics(ctx, archived); err != nil {
|
||||||
|
log.Printf("analytics sync: save archived %s: %v", tenantID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/server/templates/404.html
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>404 — WriteKit</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/assets/writekit-icon.ico" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/writekit-icon.svg" />
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
:root{--bg:#fafafa;--text:#0a0a0a;--muted:#737373;--border:#e5e5e5;--primary:#10b981}
|
||||||
|
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
|
||||||
|
.mono{font-family:'SF Mono','Fira Code','Consolas',monospace}
|
||||||
|
.layout{display:grid;grid-template-columns:200px 1fr;min-height:100vh}
|
||||||
|
aside{padding:2rem 1.5rem;border-right:1px solid var(--border);position:sticky;top:0;height:100vh;display:flex;flex-direction:column}
|
||||||
|
.sidebar-logo{font-family:'SF Mono','Fira Code',monospace;font-weight:600;font-size:14px;letter-spacing:-0.02em;margin-bottom:0.25rem}
|
||||||
|
.sidebar-tagline{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2.5rem}
|
||||||
|
.sidebar-nav{display:flex;flex-direction:column;gap:0.25rem}
|
||||||
|
.sidebar-nav a{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted);text-decoration:none;padding:0.5rem 0;transition:color 0.15s}
|
||||||
|
.sidebar-nav a:hover{color:var(--text)}
|
||||||
|
.sidebar-footer{margin-top:auto}
|
||||||
|
.env-badge{font-family:'SF Mono','Fira Code',monospace;display:inline-block;padding:0.35rem 0.75rem;border:1px solid var(--primary);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:var(--primary)}
|
||||||
|
main{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem}
|
||||||
|
.error-content{text-align:center;max-width:400px}
|
||||||
|
.error-code{font-family:'SF Mono','Fira Code',monospace;font-size:120px;font-weight:600;letter-spacing:-0.05em;line-height:1;color:var(--border);margin-bottom:1rem}
|
||||||
|
.error-title{font-size:1.5rem;font-weight:500;letter-spacing:-0.02em;margin-bottom:0.75rem}
|
||||||
|
.error-message{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);margin-bottom:2rem;line-height:1.7}
|
||||||
|
.error-actions{display:flex;gap:1rem;justify-content:center;flex-wrap:wrap}
|
||||||
|
.btn-primary{padding:12px 24px;background:var(--text);color:var(--bg);text-decoration:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;transition:all 0.2s}
|
||||||
|
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
|
||||||
|
.btn-secondary{padding:12px 24px;background:transparent;color:var(--text);border:1px solid var(--border);text-decoration:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;transition:all 0.15s}
|
||||||
|
.btn-secondary:hover{border-color:var(--text)}
|
||||||
|
@media(max-width:900px){.layout{grid-template-columns:1fr}aside{display:none}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<aside>
|
||||||
|
<div>
|
||||||
|
<div class="sidebar-logo">WriteKit</div>
|
||||||
|
<div class="sidebar-tagline">Blogging Platform</div>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/signup" style="color:var(--primary)">Create Blog →</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="env-badge">ALPHA</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main>
|
||||||
|
<div class="error-content">
|
||||||
|
<div class="error-code">404</div>
|
||||||
|
<h1 class="error-title">Page not found</h1>
|
||||||
|
<p class="error-message">The page you're looking for doesn't exist or may have been moved.</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<a href="/" class="btn-primary">Go Home</a>
|
||||||
|
<a href="/signup" class="btn-secondary">Create a Blog</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
56
internal/server/templates/expired.html
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Demo Expired — WriteKit</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/assets/writekit-icon.ico"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/writekit-icon.svg"/>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
:root{--bg:#fafafa;--text:#0a0a0a;--muted:#737373;--border:#e5e5e5;--primary:#10b981}
|
||||||
|
body{font-family:'SF Mono','Fira Code','Consolas',monospace;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem}
|
||||||
|
.container{max-width:480px;width:100%}
|
||||||
|
.header{margin-bottom:3rem}
|
||||||
|
.logo{font-weight:600;font-size:14px;letter-spacing:-0.02em;margin-bottom:0.5rem}
|
||||||
|
.subdomain{font-size:12px;color:var(--muted)}
|
||||||
|
.title{font-family:system-ui,-apple-system,sans-serif;font-size:2rem;font-weight:400;letter-spacing:-0.03em;margin-bottom:1rem}
|
||||||
|
.description{color:var(--muted);line-height:1.6;margin-bottom:2.5rem}
|
||||||
|
.section{margin-bottom:2rem}
|
||||||
|
.signup-btn{display:block;width:100%;padding:1rem 1.5rem;background:linear-gradient(135deg,#10b981,#06b6d4);color:white;text-decoration:none;font-family:inherit;font-size:14px;font-weight:500;text-align:center;border:none;cursor:pointer;transition:transform 0.2s,box-shadow 0.2s}
|
||||||
|
.signup-btn:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(16,185,129,0.3)}
|
||||||
|
.signup-note{font-size:11px;color:var(--muted);margin-top:0.75rem;text-align:center}
|
||||||
|
.divider{display:flex;align-items:center;gap:1rem;margin:2rem 0;color:var(--muted);font-size:12px}
|
||||||
|
.divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}
|
||||||
|
.secondary-actions{display:flex;gap:1rem}
|
||||||
|
.secondary-btn{flex:1;display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.875rem 1rem;background:transparent;color:var(--text);text-decoration:none;font-family:inherit;font-size:13px;border:1px solid var(--border);transition:all 0.15s}
|
||||||
|
.secondary-btn:hover{border-color:var(--text);background:var(--border)}
|
||||||
|
.secondary-btn svg{width:16px;height:16px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">WriteKit</div>
|
||||||
|
<div class="subdomain">{{SUBDOMAIN_TEXT}}</div>
|
||||||
|
</div>
|
||||||
|
<h1 class="title">Demo Expired</h1>
|
||||||
|
<p class="description">Your demo session has ended. Want to keep your blog? Sign up to make it permanent — it's free.</p>
|
||||||
|
<div class="section">
|
||||||
|
<a href="/signup" class="signup-btn">Keep My Blog — Sign Up Free</a>
|
||||||
|
<p class="signup-note">No credit card required</p>
|
||||||
|
</div>
|
||||||
|
<div class="divider">or</div>
|
||||||
|
<div class="secondary-actions">
|
||||||
|
<a href="/discord" class="secondary-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||||
|
Join Discord
|
||||||
|
</a>
|
||||||
|
<a href="/" class="secondary-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
Try Again
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
484
internal/server/templates/index.html
Normal file
|
|
@ -0,0 +1,484 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WriteKit — Full Blogging Platform. Lightweight. Yours.</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/assets/writekit-icon.ico" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/writekit-icon.svg" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" />
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
:root{--bg:#fafafa;--text:#0a0a0a;--muted:#737373;--border:#e5e5e5;--accent:{{ACCENT}};--primary:#10b981}
|
||||||
|
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
|
||||||
|
.mono{font-family:'SF Mono','Fira Code','Consolas',monospace}
|
||||||
|
.layout{display:grid;grid-template-columns:200px 1fr;min-height:100vh}
|
||||||
|
aside{padding:2rem 1.5rem;border-right:1px solid var(--border);position:sticky;top:0;height:100vh;display:flex;flex-direction:column}
|
||||||
|
.sidebar-logo{font-family:'SF Mono','Fira Code',monospace;font-weight:600;font-size:14px;letter-spacing:-0.02em;margin-bottom:0.25rem}
|
||||||
|
.sidebar-tagline{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2.5rem}
|
||||||
|
.sidebar-nav{display:flex;flex-direction:column;gap:0.25rem}
|
||||||
|
.sidebar-nav a{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted);text-decoration:none;padding:0.5rem 0;transition:color 0.15s}
|
||||||
|
.sidebar-nav a:hover{color:var(--text)}
|
||||||
|
.sidebar-divider{height:1px;background:var(--border);margin:1rem 0}
|
||||||
|
.sidebar-label{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:0.5rem}
|
||||||
|
.sidebar-footer{margin-top:auto}
|
||||||
|
.env-badge{font-family:'SF Mono','Fira Code',monospace;display:inline-block;padding:0.35rem 0.75rem;border:1px solid var(--accent);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:var(--accent)}
|
||||||
|
.version-badge{font-family:'SF Mono','Fira Code',monospace;font-size:10px;color:var(--muted);opacity:0.5;margin-top:0.5rem;cursor:default;transition:opacity 0.15s}
|
||||||
|
.version-badge:hover{opacity:1}
|
||||||
|
main{min-width:0}
|
||||||
|
.hero{position:relative;padding:5rem 3rem 4rem;border-bottom:1px solid var(--border);overflow:hidden}
|
||||||
|
.hero-canvas{position:absolute;top:0;right:0;width:45%;height:100%;opacity:0.6;mask-image:linear-gradient(to left,black,transparent);-webkit-mask-image:linear-gradient(to left,black,transparent)}
|
||||||
|
.hero>*:not(.hero-canvas){position:relative;z-index:1}
|
||||||
|
.hero-label{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.15em;margin-bottom:1.5rem}
|
||||||
|
.hero h1{font-family:system-ui,-apple-system,sans-serif;font-size:clamp(2rem,4vw,3rem);font-weight:500;letter-spacing:-0.03em;line-height:1.15;margin-bottom:1.5rem;max-width:600px}
|
||||||
|
.hero-sub{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);max-width:550px;line-height:1.7}
|
||||||
|
.hero-sub code{color:var(--text);background:var(--border);padding:0.15em 0.4em;font-size:0.95em}
|
||||||
|
.section{padding:4rem 3rem;border-bottom:1px solid var(--border)}
|
||||||
|
.section-label{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.15em;margin-bottom:2rem}
|
||||||
|
.hero-cta{margin-top:2.5rem}
|
||||||
|
.hero-cta .demo-btn{width:auto;padding:14px 32px;font-size:14px}
|
||||||
|
.hero-cta .demo-note{text-align:left}
|
||||||
|
.section-features{border-top:1px solid var(--border);border-bottom:1px solid var(--border);margin-top:-1px}
|
||||||
|
.features-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--border)}
|
||||||
|
.feature{background:var(--bg);padding:2rem}
|
||||||
|
.feature-num{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);margin-bottom:0.75rem}
|
||||||
|
.feature h3{font-size:1rem;font-weight:500;margin-bottom:0.5rem;letter-spacing:-0.01em}
|
||||||
|
.feature p{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);line-height:1.6}
|
||||||
|
.demo-btn{width:100%;margin-top:16px;padding:12px 24px;background:var(--text);color:var(--bg);border:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;cursor:pointer;transition:all 0.2s}
|
||||||
|
.demo-btn:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
|
||||||
|
.demo-btn:disabled{opacity:0.4;cursor:not-allowed}
|
||||||
|
.demo-note{margin-top:1rem;font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-align:center}
|
||||||
|
.mission{max-width:650px}
|
||||||
|
.mission h2{font-size:1.5rem;font-weight:400;letter-spacing:-0.02em;line-height:1.5;margin-bottom:1.5rem}
|
||||||
|
.mission p{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);line-height:1.7}
|
||||||
|
.section-cta{text-align:center;padding:5rem 3rem;margin-top:-1px}
|
||||||
|
.cta-content h2{font-size:1.75rem;font-weight:500;letter-spacing:-0.02em;margin-bottom:0.75rem}
|
||||||
|
.cta-content p{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--muted);margin-bottom:2rem}
|
||||||
|
.cta-buttons{display:flex;gap:1rem;justify-content:center;flex-wrap:wrap}
|
||||||
|
.cta-primary{padding:14px 32px;background:var(--text);color:var(--bg);text-decoration:none;font-family:'SF Mono','Fira Code',monospace;font-size:14px;transition:all 0.2s}
|
||||||
|
.cta-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
|
||||||
|
.cta-secondary{padding:14px 32px;background:transparent;color:var(--text);border:1px solid var(--border);font-family:'SF Mono','Fira Code',monospace;font-size:14px;cursor:pointer;transition:all 0.2s}
|
||||||
|
.cta-secondary:hover{border-color:var(--text)}
|
||||||
|
footer{padding:4rem 3rem;background:var(--text);color:var(--bg);margin-top:-1px}
|
||||||
|
.footer-content{display:flex;justify-content:space-between;align-items:flex-start;gap:3rem;max-width:900px}
|
||||||
|
.footer-brand{flex:1}
|
||||||
|
.footer-logo{font-family:'SF Mono','Fira Code',monospace;font-weight:600;font-size:16px;margin-bottom:0.5rem}
|
||||||
|
.footer-tagline{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted);margin-bottom:1.5rem}
|
||||||
|
.footer-copy{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted)}
|
||||||
|
.footer-links{display:flex;gap:3rem}
|
||||||
|
.footer-col h4{font-family:'SF Mono','Fira Code',monospace;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;color:var(--muted);margin-bottom:1rem;font-weight:normal}
|
||||||
|
.footer-col a{display:flex;align-items:center;gap:0.5rem;font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:var(--bg);text-decoration:none;padding:0.35rem 0;transition:opacity 0.15s}
|
||||||
|
.footer-col a:hover{opacity:0.7}
|
||||||
|
.footer-col a svg{width:16px;height:16px}
|
||||||
|
.demo-modal{display:none;position:fixed;inset:0;z-index:1000;align-items:center;justify-content:center}
|
||||||
|
.demo-modal.active{display:flex}
|
||||||
|
.demo-modal-backdrop{position:absolute;inset:0;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)}
|
||||||
|
.demo-modal-content{position:relative;background:white;border:1px solid var(--border);width:100%;max-width:420px;margin:1rem;box-shadow:0 25px 50px -12px rgba(0,0,0,0.25)}
|
||||||
|
.demo-step{display:none;padding:2rem}
|
||||||
|
.demo-step.active{display:block;animation:fadeIn 0.3s ease}
|
||||||
|
.demo-step-header{margin-bottom:1.5rem}
|
||||||
|
.demo-step-indicator{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;display:block;margin-bottom:0.75rem}
|
||||||
|
.demo-step-header h2{font-size:1.25rem;font-weight:500;margin-bottom:0.5rem}
|
||||||
|
.demo-step-header p{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted)}
|
||||||
|
.demo-name-input{width:100%;padding:14px 16px;border:1px solid var(--border);font-family:inherit;font-size:15px;outline:none;transition:border-color 0.15s}
|
||||||
|
.demo-name-input:focus{border-color:var(--text)}
|
||||||
|
.demo-step-footer{display:flex;justify-content:flex-end;gap:0.75rem;margin-top:1.5rem}
|
||||||
|
.demo-next,.demo-launch{padding:12px 24px;background:var(--text);color:var(--bg);border:none;font-family:'SF Mono','Fira Code',monospace;font-size:13px;cursor:pointer;transition:all 0.2s}
|
||||||
|
.demo-next:hover:not(:disabled),.demo-launch:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.15)}
|
||||||
|
.demo-next:disabled,.demo-launch:disabled{opacity:0.4;cursor:not-allowed}
|
||||||
|
.demo-back{padding:12px 24px;background:transparent;color:var(--muted);border:1px solid var(--border);font-family:'SF Mono','Fira Code',monospace;font-size:13px;cursor:pointer;transition:all 0.15s}
|
||||||
|
.demo-back:hover{border-color:var(--text);color:var(--text)}
|
||||||
|
.demo-launch{background:linear-gradient(135deg,#10b981,#06b6d4)}
|
||||||
|
.demo-launch:hover:not(:disabled){box-shadow:0 8px 24px rgba(16,185,129,0.3)}
|
||||||
|
.color-picker{display:flex;gap:0.75rem;flex-wrap:wrap}
|
||||||
|
.color-swatch{width:48px;height:48px;border:2px solid transparent;cursor:pointer;transition:all 0.15s;position:relative}
|
||||||
|
.color-swatch:hover{transform:scale(1.1)}
|
||||||
|
.color-swatch.selected{border-color:var(--text)}
|
||||||
|
.color-swatch.selected::after{content:'✓';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:white;font-size:18px;text-shadow:0 1px 2px rgba(0,0,0,0.3)}
|
||||||
|
.launch-progress{margin-top:1.5rem;padding:1rem;background:#f5f5f5;border:1px solid var(--border)}
|
||||||
|
.launch-progress.active{display:block;animation:fadeIn 0.3s ease}
|
||||||
|
.progress-step{display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0;font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted)}
|
||||||
|
.progress-step.active{color:var(--text)}
|
||||||
|
.progress-step.done{color:var(--primary)}
|
||||||
|
.progress-dot{width:8px;height:8px;background:var(--border);transition:all 0.3s}
|
||||||
|
.progress-step.active .progress-dot{background:var(--text);animation:pulse 1s infinite}
|
||||||
|
.progress-step.done .progress-dot{background:var(--primary)}
|
||||||
|
.launch-success{text-align:center;padding:2rem}
|
||||||
|
.launch-success.active{display:block;animation:successPop 0.5s cubic-bezier(0.34,1.56,0.64,1)}
|
||||||
|
.success-icon{width:48px;height:48px;background:linear-gradient(135deg,#10b981,#06b6d4);display:flex;align-items:center;justify-content:center;margin:0 auto 1rem}
|
||||||
|
.success-icon svg{width:24px;height:24px;color:white}
|
||||||
|
.success-url{font-family:'SF Mono','Fira Code',monospace;font-size:14px;font-weight:500;margin-bottom:0.5rem}
|
||||||
|
.success-redirect{font-family:'SF Mono','Fira Code',monospace;font-size:12px;color:var(--muted)}
|
||||||
|
.demo-modal .launch-progress{display:block;margin-top:0;padding:0;background:transparent;border:none}
|
||||||
|
.demo-modal .launch-success{display:block;padding:1rem 0}
|
||||||
|
@keyframes fadeIn{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
|
||||||
|
@keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.3)}}
|
||||||
|
@keyframes successPop{0%{transform:scale(0.8);opacity:0}100%{transform:scale(1);opacity:1}}
|
||||||
|
@media(max-width:1024px){.features-grid{grid-template-columns:repeat(2,1fr)}}
|
||||||
|
@media(max-width:900px){.layout{grid-template-columns:1fr}aside{position:relative;height:auto;border-right:none;border-bottom:1px solid var(--border);flex-direction:row;align-items:center;justify-content:space-between;padding:1rem 1.5rem}.sidebar-tagline{margin-bottom:0}.sidebar-nav,.sidebar-divider,.sidebar-label,.sidebar-footer{display:none}.hero,.section{padding:3rem 2rem}footer{padding:3rem 2rem}.footer-content{flex-direction:column}}
|
||||||
|
@media(max-width:768px){.features-grid{grid-template-columns:1fr}}
|
||||||
|
@media(max-width:480px){.hero,.section{padding:2rem 1.5rem}.footer-links{flex-direction:column;gap:2rem}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<aside>
|
||||||
|
<div>
|
||||||
|
<div class="sidebar-logo">WriteKit</div>
|
||||||
|
<div class="sidebar-tagline">Blogging Platform</div>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="#why">Why WriteKit</a>
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="/signup" style="color:var(--primary)">Create Blog →</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-divider"></div>
|
||||||
|
<div class="sidebar-label">Resources</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="/docs">Documentation</a>
|
||||||
|
<a href="/discord">Discord</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="env-badge">ALPHA</div>
|
||||||
|
<div class="version-badge" title="{{COMMIT}}">{{VERSION}}</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<canvas class="hero-canvas" id="dither-canvas"></canvas>
|
||||||
|
<p class="hero-label">Blog Hosting for Developers / 2025</p>
|
||||||
|
<h1>Your Words,<br>Your Platform</h1>
|
||||||
|
<p class="hero-sub">Spin up a beautiful, fast blog in seconds. <code>SQLite</code>-powered, <code>markdown</code>-native, infinitely customizable.</p>
|
||||||
|
<div class="hero-cta">
|
||||||
|
<button class="demo-btn" id="try-demo">Try Demo</button>
|
||||||
|
<p class="demo-note">{{DEMO_MINUTES}} minute demo. <a href="/signup" style="color:var(--primary)">Create a real blog</a> instead.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section" id="why">
|
||||||
|
<p class="section-label">Why WriteKit</p>
|
||||||
|
<div class="mission">
|
||||||
|
<h2>We built WriteKit because blogging platforms got complicated.</h2>
|
||||||
|
<p>Ghost is heavy. Hashnode is bloated. Medium doesn't care about developers. Hugo outputs static sites — great, until you want comments, logins and analytics without bolting on five services.<br><br>WriteKit is a fully featured platform for developers. Comments, reactions, search, analytics, monetization, API — everything works out of the box. Deploy in seconds, own your data forever.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section-features" id="features">
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature"><p class="feature-num">01</p><h3>Comments & Reactions</h3><p>Threaded comments and emoji reactions. No Disqus, no third-party scripts.</p></div>
|
||||||
|
<div class="feature"><p class="feature-num">02</p><h3>Full-text Search</h3><p>SQLite FTS5 powers instant search. Fast, local, no external service.</p></div>
|
||||||
|
<div class="feature"><p class="feature-num">03</p><h3>Privacy-first Analytics</h3><p>Views, referrers, browsers — no cookies, no tracking pixels.</p></div>
|
||||||
|
<div class="feature"><p class="feature-num">04</p><h3>REST API</h3><p>Full programmatic access. Create posts, manage content, build integrations.</p></div>
|
||||||
|
<div class="feature"><p class="feature-num">05</p><h3>Markdown Native</h3><p>Write how you already write. YAML frontmatter, syntax highlighting.</p></div>
|
||||||
|
<div class="feature"><p class="feature-num">06</p><h3>Custom Domains</h3><p>Your domain or *.writekit.dev. SSL included automatically.</p></div>
|
||||||
|
<div class="feature"><p class="feature-num">07</p><h3>Own Your Data</h3><p>Export anytime. JSON, Markdown, full backup. No lock-in ever.</p></div>
|
||||||
|
<div class="feature"><p class="feature-num">08</p><h3>One-click Deploy</h3><p>No DevOps required. One button, your instance is live.</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section section-cta" id="cta">
|
||||||
|
<div class="cta-content">
|
||||||
|
<h2>Ready to start writing?</h2>
|
||||||
|
<p>Deploy your blog in seconds. No credit card required.</p>
|
||||||
|
<div class="cta-buttons">
|
||||||
|
<a href="/signup" class="cta-primary">Create Your Blog</a>
|
||||||
|
<button class="cta-secondary" id="try-demo-bottom">Try Demo First</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="demo-modal" id="demo-modal">
|
||||||
|
<div class="demo-modal-backdrop"></div>
|
||||||
|
<div class="demo-modal-content">
|
||||||
|
<div class="demo-step active" data-step="1">
|
||||||
|
<div class="demo-step-header">
|
||||||
|
<span class="demo-step-indicator">1 / 2</span>
|
||||||
|
<h2>What's your name?</h2>
|
||||||
|
<p>We'll use this to personalize your blog.</p>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="demo-name" class="demo-name-input" placeholder="Your name" autofocus autocomplete="off">
|
||||||
|
<div class="demo-step-footer">
|
||||||
|
<button class="demo-back" id="demo-skip">Skip</button>
|
||||||
|
<button class="demo-next" id="demo-next-1" disabled>Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="demo-step" data-step="2">
|
||||||
|
<div class="demo-step-header">
|
||||||
|
<span class="demo-step-indicator">2 / 2</span>
|
||||||
|
<h2>Pick a color</h2>
|
||||||
|
<p>Choose an accent color for your blog.</p>
|
||||||
|
</div>
|
||||||
|
<div class="color-picker">
|
||||||
|
<button class="color-swatch selected" data-color="#10b981" style="background:#10b981" title="Emerald"></button>
|
||||||
|
<button class="color-swatch" data-color="#3b82f6" style="background:#3b82f6" title="Blue"></button>
|
||||||
|
<button class="color-swatch" data-color="#8b5cf6" style="background:#8b5cf6" title="Purple"></button>
|
||||||
|
<button class="color-swatch" data-color="#f97316" style="background:#f97316" title="Orange"></button>
|
||||||
|
<button class="color-swatch" data-color="#ef4444" style="background:#ef4444" title="Rose"></button>
|
||||||
|
<button class="color-swatch" data-color="#64748b" style="background:#64748b" title="Slate"></button>
|
||||||
|
</div>
|
||||||
|
<div class="demo-step-footer">
|
||||||
|
<button class="demo-back" id="demo-back-2">← Back</button>
|
||||||
|
<button class="demo-launch" id="demo-launch">Launch Demo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="demo-step" data-step="3">
|
||||||
|
<div class="demo-step-header"><h2>Launching your demo...</h2></div>
|
||||||
|
<div class="launch-progress active">
|
||||||
|
<div class="progress-step" data-step="1"><span class="progress-dot"></span><span>Creating database...</span></div>
|
||||||
|
<div class="progress-step" data-step="2"><span class="progress-dot"></span><span>Configuring settings...</span></div>
|
||||||
|
<div class="progress-step" data-step="3"><span class="progress-dot"></span><span>Adding welcome post...</span></div>
|
||||||
|
<div class="progress-step" data-step="4"><span class="progress-dot"></span><span>Ready!</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="demo-step" data-step="4">
|
||||||
|
<div class="launch-success active">
|
||||||
|
<div class="success-icon"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg></div>
|
||||||
|
<div class="success-url" id="success-url"></div>
|
||||||
|
<div class="success-redirect">Redirecting you now...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<div class="footer-content">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<div class="footer-logo">WriteKit</div>
|
||||||
|
<div class="footer-tagline">Your Words, Your Platform</div>
|
||||||
|
<div class="footer-copy">© 2025 WriteKit. All rights reserved.</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>Product</h4>
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="/signup">Create Blog</a>
|
||||||
|
<a href="/docs">Documentation</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<h4>Community</h4>
|
||||||
|
<a href="/discord"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>Discord</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const $ = s => document.querySelector(s)
|
||||||
|
const $$ = s => document.querySelectorAll(s)
|
||||||
|
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const modal = $('#demo-modal')
|
||||||
|
const backdrop = modal.querySelector('.demo-modal-backdrop')
|
||||||
|
const nameInput = $('#demo-name')
|
||||||
|
const nextBtn = $('#demo-next-1')
|
||||||
|
const backBtn = $('#demo-back-2')
|
||||||
|
const launchBtn = $('#demo-launch')
|
||||||
|
const successUrl = $('#success-url')
|
||||||
|
const colorSwatches = $$('.color-swatch')
|
||||||
|
|
||||||
|
let demoName = ''
|
||||||
|
let demoColor = '#10b981'
|
||||||
|
|
||||||
|
const openDemoModal = () => {
|
||||||
|
modal.classList.add('active')
|
||||||
|
setTimeout(() => nameInput.focus(), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetModal = () => {
|
||||||
|
nameInput.value = ''
|
||||||
|
demoName = ''
|
||||||
|
nextBtn.disabled = true
|
||||||
|
goToModalStep(1)
|
||||||
|
colorSwatches.forEach(s => s.classList.remove('selected'))
|
||||||
|
colorSwatches[0].classList.add('selected')
|
||||||
|
demoColor = '#10b981'
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToModalStep = step => {
|
||||||
|
modal.querySelectorAll('.demo-step').forEach(el => el.classList.remove('active'))
|
||||||
|
modal.querySelector(`.demo-step[data-step="${step}"]`).classList.add('active')
|
||||||
|
if (step === 1) setTimeout(() => nameInput.focus(), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setProgressStep = n => {
|
||||||
|
modal.querySelectorAll('.progress-step').forEach(el => {
|
||||||
|
const step = parseInt(el.dataset.step)
|
||||||
|
el.classList.remove('active', 'done')
|
||||||
|
if (step < n) el.classList.add('done')
|
||||||
|
if (step === n) el.classList.add('active')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchDemo = async () => {
|
||||||
|
if (!demoName) return
|
||||||
|
launchBtn.disabled = true
|
||||||
|
launchBtn.textContent = 'Launching...'
|
||||||
|
goToModalStep(3)
|
||||||
|
setProgressStep(1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/demo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: demoName, color: demoColor })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (res.ok && data.url) {
|
||||||
|
setProgressStep(2)
|
||||||
|
await sleep(150)
|
||||||
|
setProgressStep(3)
|
||||||
|
await sleep(150)
|
||||||
|
setProgressStep(4)
|
||||||
|
await sleep(150)
|
||||||
|
goToModalStep(4)
|
||||||
|
successUrl.textContent = data.url
|
||||||
|
await sleep(300)
|
||||||
|
location.href = data.url
|
||||||
|
} else {
|
||||||
|
goToModalStep(2)
|
||||||
|
launchBtn.disabled = false
|
||||||
|
launchBtn.textContent = 'Launch Demo'
|
||||||
|
alert(data.error || 'Failed to create demo')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
goToModalStep(2)
|
||||||
|
launchBtn.disabled = false
|
||||||
|
launchBtn.textContent = 'Launch Demo'
|
||||||
|
alert('Error creating demo')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#try-demo').addEventListener('click', openDemoModal)
|
||||||
|
$('#try-demo-bottom').addEventListener('click', openDemoModal)
|
||||||
|
|
||||||
|
backdrop.addEventListener('click', () => {
|
||||||
|
if (!launchBtn.disabled || modal.querySelector('.demo-step[data-step="1"].active') || modal.querySelector('.demo-step[data-step="2"].active')) {
|
||||||
|
modal.classList.remove('active')
|
||||||
|
resetModal()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('active')) {
|
||||||
|
if (modal.querySelector('.demo-step[data-step="1"].active') || modal.querySelector('.demo-step[data-step="2"].active')) {
|
||||||
|
modal.classList.remove('active')
|
||||||
|
resetModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && modal.classList.contains('active')) {
|
||||||
|
if (modal.querySelector('.demo-step[data-step="1"].active') && !nextBtn.disabled) goToModalStep(2)
|
||||||
|
else if (modal.querySelector('.demo-step[data-step="2"].active')) launchDemo()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nameInput.addEventListener('input', () => {
|
||||||
|
demoName = nameInput.value.trim()
|
||||||
|
nextBtn.disabled = !demoName.length
|
||||||
|
})
|
||||||
|
|
||||||
|
nextBtn.addEventListener('click', () => goToModalStep(2))
|
||||||
|
backBtn.addEventListener('click', () => goToModalStep(1))
|
||||||
|
launchBtn.addEventListener('click', launchDemo)
|
||||||
|
|
||||||
|
$('#demo-skip').addEventListener('click', () => {
|
||||||
|
demoName = 'Demo User'
|
||||||
|
launchDemo()
|
||||||
|
})
|
||||||
|
|
||||||
|
colorSwatches.forEach(swatch => {
|
||||||
|
swatch.addEventListener('click', () => {
|
||||||
|
colorSwatches.forEach(s => s.classList.remove('selected'))
|
||||||
|
swatch.classList.add('selected')
|
||||||
|
demoColor = swatch.dataset.color
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
$$('a[href^="#"]').forEach(anchor => {
|
||||||
|
anchor.addEventListener('click', e => {
|
||||||
|
e.preventDefault()
|
||||||
|
document.querySelector(anchor.getAttribute('href'))?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
;(() => {
|
||||||
|
const settings = { pixelSize: 8, gridSize: 4, speed: 0.05, colorShift: 0.6, colors: { bright: [0.063, 0.725, 0.506], mid: [0.047, 0.545, 0.38], dark: [0.031, 0.363, 0.253], bg: [0.2, 0.2, 0.2] } }
|
||||||
|
const canvas = $('#dither-canvas')
|
||||||
|
if (!canvas) return
|
||||||
|
const gl = canvas.getContext('webgl2')
|
||||||
|
if (!gl) return
|
||||||
|
|
||||||
|
const vs = `#version 300 es
|
||||||
|
in vec2 a_position;
|
||||||
|
out vec2 v_uv;
|
||||||
|
void main() { v_uv = a_position * 0.5 + 0.5; gl_Position = vec4(a_position, 0.0, 1.0); }`
|
||||||
|
|
||||||
|
const fs = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
in vec2 v_uv;
|
||||||
|
out vec4 fragColor;
|
||||||
|
uniform vec2 u_resolution;
|
||||||
|
uniform float u_time, u_pixelSize, u_gridSize, u_speed, u_colorShift;
|
||||||
|
uniform vec3 u_color1, u_color2, u_color3, u_bgColor;
|
||||||
|
float bayer8(vec2 p) { ivec2 P = ivec2(mod(floor(p), 8.0)); int i = P.x + P.y * 8; int b[64] = int[64](0,32,8,40,2,34,10,42,48,16,56,24,50,18,58,26,12,44,4,36,14,46,6,38,60,28,52,20,62,30,54,22,3,35,11,43,1,33,9,41,51,19,59,27,49,17,57,25,15,47,7,39,13,45,5,37,63,31,55,23,61,29,53,21); return float(b[i]) / 64.0; }
|
||||||
|
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
||||||
|
float noise(vec2 p) { vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y); }
|
||||||
|
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; } return v; }
|
||||||
|
void main() {
|
||||||
|
vec2 pixelUV = floor(v_uv * u_resolution / u_pixelSize) * u_pixelSize / u_resolution;
|
||||||
|
vec2 p = pixelUV * 3.0; float t = u_time * u_speed;
|
||||||
|
float pattern = fbm(p + vec2(t * 0.5, t * 0.3)) * 0.5 + fbm(p * 1.5 - vec2(t * 0.4, -t * 0.2)) * 0.3 + fbm(p * 0.5 + vec2(-t * 0.3, t * 0.5)) * 0.2 + 0.1 * sin(p.x * 2.0 + t) * sin(p.y * 2.0 - t * 0.7);
|
||||||
|
float luma = clamp(smoothstep(0.1, 0.9, pow(pattern, 0.7)) + u_colorShift * sin(u_time * 0.3) * 0.3, 0.0, 1.0);
|
||||||
|
float threshold = bayer8(floor(v_uv * u_resolution / u_gridSize));
|
||||||
|
float level = luma * 3.0; int band = int(floor(level)); float frac = fract(level);
|
||||||
|
vec3 result = band >= 2 ? (frac > threshold ? u_color1 : u_color2) : band == 1 ? (frac > threshold ? u_color2 : u_color3) : (frac > threshold ? u_color3 : u_bgColor);
|
||||||
|
fragColor = vec4(result, 1.0);
|
||||||
|
}`
|
||||||
|
|
||||||
|
const createShader = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null }
|
||||||
|
const vertexShader = createShader(gl.VERTEX_SHADER, vs)
|
||||||
|
const fragmentShader = createShader(gl.FRAGMENT_SHADER, fs)
|
||||||
|
const program = gl.createProgram()
|
||||||
|
gl.attachShader(program, vertexShader)
|
||||||
|
gl.attachShader(program, fragmentShader)
|
||||||
|
gl.linkProgram(program)
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return
|
||||||
|
|
||||||
|
const vao = gl.createVertexArray()
|
||||||
|
gl.bindVertexArray(vao)
|
||||||
|
const buf = gl.createBuffer()
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW)
|
||||||
|
const posLoc = gl.getAttribLocation(program, 'a_position')
|
||||||
|
gl.enableVertexAttribArray(posLoc)
|
||||||
|
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0)
|
||||||
|
|
||||||
|
const u = { resolution: gl.getUniformLocation(program, 'u_resolution'), time: gl.getUniformLocation(program, 'u_time'), pixelSize: gl.getUniformLocation(program, 'u_pixelSize'), gridSize: gl.getUniformLocation(program, 'u_gridSize'), speed: gl.getUniformLocation(program, 'u_speed'), colorShift: gl.getUniformLocation(program, 'u_colorShift'), color1: gl.getUniformLocation(program, 'u_color1'), color2: gl.getUniformLocation(program, 'u_color2'), color3: gl.getUniformLocation(program, 'u_color3'), bgColor: gl.getUniformLocation(program, 'u_bgColor') }
|
||||||
|
|
||||||
|
gl.useProgram(program)
|
||||||
|
gl.uniform3fv(u.color1, settings.colors.bright)
|
||||||
|
gl.uniform3fv(u.color2, settings.colors.mid)
|
||||||
|
gl.uniform3fv(u.color3, settings.colors.dark)
|
||||||
|
gl.uniform3fv(u.bgColor, settings.colors.bg)
|
||||||
|
|
||||||
|
const resize = () => { const dpr = Math.min(devicePixelRatio, 2); canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr }
|
||||||
|
resize()
|
||||||
|
addEventListener('resize', resize)
|
||||||
|
|
||||||
|
const render = time => {
|
||||||
|
time *= 0.001
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||||
|
gl.useProgram(program)
|
||||||
|
gl.bindVertexArray(vao)
|
||||||
|
gl.uniform2f(u.resolution, canvas.width, canvas.height)
|
||||||
|
gl.uniform1f(u.time, time)
|
||||||
|
gl.uniform1f(u.pixelSize, settings.pixelSize)
|
||||||
|
gl.uniform1f(u.gridSize, settings.gridSize)
|
||||||
|
gl.uniform1f(u.speed, settings.speed)
|
||||||
|
gl.uniform1f(u.colorShift, settings.colorShift)
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||||
|
requestAnimationFrame(render)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(render)
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
472
internal/server/templates/signup.html
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Sign Up — WriteKit</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/assets/writekit-icon.ico"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/writekit-icon.svg"/>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
:root{--bg:#0a0a0a;--bg-elevated:#111111;--bg-subtle:#1a1a1a;--text:#fafafa;--text-muted:#737373;--text-dim:#525252;--border:#262626;--border-focus:#404040;--accent:{{ACCENT}};--emerald:#10b981;--cyan:#06b6d4;--red:#ef4444}
|
||||||
|
html,body{height:100%}
|
||||||
|
body{font-family:'SF Mono','Fira Code','Consolas',monospace;background:var(--bg);color:var(--text);line-height:1.6;overflow:hidden}
|
||||||
|
.layout{display:grid;grid-template-columns:280px 1fr;height:100vh}
|
||||||
|
.sidebar{background:var(--bg);border-right:1px solid var(--border);padding:2.5rem 2rem;display:flex;flex-direction:column}
|
||||||
|
.sidebar-header{margin-bottom:3rem}
|
||||||
|
.logo{font-size:15px;font-weight:600;letter-spacing:-0.02em;margin-bottom:0.35rem}
|
||||||
|
.tagline{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.1em}
|
||||||
|
.sidebar-content{flex:1;display:flex;flex-direction:column;justify-content:center}
|
||||||
|
.step-indicator{display:flex;flex-direction:column;gap:1rem}
|
||||||
|
.step-item{display:flex;align-items:center;gap:1rem;font-size:12px;color:var(--text-dim);transition:all 0.4s ease}
|
||||||
|
.step-item.active{color:var(--text)}
|
||||||
|
.step-item.completed{color:var(--emerald)}
|
||||||
|
.step-dot{width:8px;height:8px;border:1px solid var(--border);background:transparent;transition:all 0.4s ease}
|
||||||
|
.step-item.active .step-dot{background:var(--text);border-color:var(--text);box-shadow:0 0 12px rgba(250,250,250,0.3)}
|
||||||
|
.step-item.completed .step-dot{background:var(--emerald);border-color:var(--emerald)}
|
||||||
|
.sidebar-footer{margin-top:auto}
|
||||||
|
.env-badge{display:inline-block;padding:0.35rem 0.75rem;border:1px solid var(--accent);font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--accent)}
|
||||||
|
.main{display:flex;align-items:center;justify-content:center;padding:2rem;position:relative;overflow:hidden}
|
||||||
|
.main::before{content:'';position:absolute;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:60px 60px;opacity:0.3;mask-image:radial-gradient(ellipse at center,black 0%,transparent 70%)}
|
||||||
|
.step-container{position:relative;width:100%;max-width:480px;z-index:1}
|
||||||
|
.step{position:absolute;width:100%;opacity:0;visibility:hidden;transform:translateY(30px);transition:all 0.5s cubic-bezier(0.16,1,0.3,1)}
|
||||||
|
.step.active{position:relative;opacity:1;visibility:visible;transform:translateY(0)}
|
||||||
|
.step.exit-up{transform:translateY(-30px)}
|
||||||
|
.step-header{margin-bottom:2.5rem}
|
||||||
|
.step-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.15em;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem}
|
||||||
|
.step-label::before{content:'>';color:var(--emerald)}
|
||||||
|
.step-title{font-size:clamp(1.75rem,3vw,2.25rem);font-weight:500;letter-spacing:-0.03em;line-height:1.2;margin-bottom:0.75rem}
|
||||||
|
.step-desc{font-size:13px;color:var(--text-muted);line-height:1.7}
|
||||||
|
.auth-buttons{display:flex;flex-direction:column;gap:0.75rem}
|
||||||
|
.auth-btn{display:flex;align-items:center;justify-content:center;gap:0.75rem;padding:1rem 1.5rem;border:1px solid var(--border);background:var(--bg-elevated);color:var(--text);font-family:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:all 0.2s ease;text-decoration:none}
|
||||||
|
.auth-btn:hover{border-color:var(--border-focus);background:var(--bg-subtle)}
|
||||||
|
.auth-btn.primary{background:var(--text);color:var(--bg);border-color:var(--text)}
|
||||||
|
.auth-btn.primary:hover{background:#e5e5e5;border-color:#e5e5e5;transform:translateY(-2px);box-shadow:0 8px 24px rgba(250,250,250,0.15)}
|
||||||
|
.auth-btn svg{width:20px;height:20px;flex-shrink:0}
|
||||||
|
.auth-divider{display:flex;align-items:center;gap:1rem;margin:0.5rem 0}
|
||||||
|
.auth-divider::before,.auth-divider::after{content:'';flex:1;height:1px;background:var(--border)}
|
||||||
|
.auth-divider span{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.1em}
|
||||||
|
.user-greeting{display:flex;flex-direction:column}
|
||||||
|
.user-avatar{width:64px;height:64px;border-radius:50%;margin-bottom:1.5rem;border:2px solid var(--border);display:none;object-fit:cover}
|
||||||
|
.user-avatar.loaded{display:block;animation:avatarPop 0.5s cubic-bezier(0.34,1.56,0.64,1)}
|
||||||
|
@keyframes avatarPop{0%{transform:scale(0);opacity:0}100%{transform:scale(1);opacity:1}}
|
||||||
|
.subdomain-form{margin-bottom:1.5rem}
|
||||||
|
.input-row{display:flex;align-items:stretch;border:1px solid var(--border);background:var(--bg-elevated);transition:all 0.2s ease}
|
||||||
|
.input-row:focus-within{border-color:var(--border-focus)}
|
||||||
|
.input-row.valid{border-color:var(--emerald)}
|
||||||
|
.input-row.invalid{border-color:var(--red)}
|
||||||
|
.subdomain-input{flex:1;padding:1rem 1.25rem;background:transparent;border:none;color:var(--text);font-family:inherit;font-size:15px;outline:none}
|
||||||
|
.subdomain-input::placeholder{color:var(--text-dim)}
|
||||||
|
.subdomain-suffix{padding:1rem 1.25rem;background:var(--bg-subtle);color:var(--text-muted);font-size:15px;display:flex;align-items:center;border-left:1px solid var(--border)}
|
||||||
|
.input-status{height:1.5rem;margin-top:0.75rem;font-size:12px;display:flex;align-items:center;gap:0.5rem}
|
||||||
|
.input-status.available{color:var(--emerald)}
|
||||||
|
.input-status.unavailable{color:var(--red)}
|
||||||
|
.input-status.checking{color:var(--text-muted)}
|
||||||
|
.input-status .dot{width:6px;height:6px;background:currentColor;animation:pulse 1s infinite}
|
||||||
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
|
||||||
|
.btn-row{display:flex;gap:0.75rem}
|
||||||
|
.btn{padding:1rem 2rem;font-family:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:all 0.2s ease;border:1px solid var(--border);background:var(--bg-elevated);color:var(--text)}
|
||||||
|
.btn:hover:not(:disabled){border-color:var(--border-focus);background:var(--bg-subtle)}
|
||||||
|
.btn:disabled{opacity:0.4;cursor:not-allowed}
|
||||||
|
.btn.primary{flex:1;background:linear-gradient(135deg,var(--emerald),var(--cyan));border:none;color:white}
|
||||||
|
.btn.primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 8px 24px rgba(16,185,129,0.3)}
|
||||||
|
.btn.primary:disabled{background:var(--border);transform:none;box-shadow:none}
|
||||||
|
.btn-back{width:48px;padding:1rem;display:flex;align-items:center;justify-content:center}
|
||||||
|
.btn-back svg{width:16px;height:16px}
|
||||||
|
.progress-steps{background:var(--bg-elevated);border:1px solid var(--border);padding:1.5rem}
|
||||||
|
.progress-step{display:flex;align-items:center;gap:1rem;padding:0.75rem 0;font-size:13px;color:var(--text-dim);transition:all 0.4s ease}
|
||||||
|
.progress-step.active{color:var(--text)}
|
||||||
|
.progress-step.done{color:var(--emerald)}
|
||||||
|
.progress-step .dot{width:8px;height:8px;background:var(--border);flex-shrink:0;transition:all 0.3s ease}
|
||||||
|
.progress-step.active .dot{background:var(--text);animation:progressPulse 1s infinite}
|
||||||
|
.progress-step.done .dot{background:var(--emerald)}
|
||||||
|
@keyframes progressPulse{0%,100%{transform:scale(1);box-shadow:0 0 0 0 rgba(250,250,250,0.4)}50%{transform:scale(1.2);box-shadow:0 0 12px 2px rgba(250,250,250,0.2)}}
|
||||||
|
.success-content{text-align:center}
|
||||||
|
.success-icon{width:72px;height:72px;margin:0 auto 2rem;background:linear-gradient(135deg,var(--emerald),var(--cyan));display:flex;align-items:center;justify-content:center;animation:successPop 0.6s cubic-bezier(0.34,1.56,0.64,1)}
|
||||||
|
@keyframes successPop{0%{transform:scale(0);opacity:0}50%{transform:scale(1.1)}100%{transform:scale(1);opacity:1}}
|
||||||
|
.success-icon svg{width:32px;height:32px;color:white;animation:checkDraw 0.4s 0.3s ease-out both}
|
||||||
|
@keyframes checkDraw{0%{stroke-dashoffset:24;opacity:0}100%{stroke-dashoffset:0;opacity:1}}
|
||||||
|
.success-icon svg path{stroke-dasharray:24;stroke-dashoffset:24;animation:checkDraw 0.4s 0.3s ease-out forwards}
|
||||||
|
.success-url{font-size:18px;font-weight:600;margin-bottom:0.5rem;background:linear-gradient(135deg,var(--emerald),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||||
|
.success-redirect{font-size:12px;color:var(--text-muted);display:flex;align-items:center;justify-content:center;gap:0.5rem}
|
||||||
|
.success-redirect .dot{width:6px;height:6px;background:var(--text-muted);animation:pulse 1s infinite}
|
||||||
|
.back-link{position:absolute;top:2rem;left:2rem;font-size:12px;color:var(--text-muted);text-decoration:none;display:flex;align-items:center;gap:0.5rem;transition:color 0.2s;z-index:10}
|
||||||
|
.back-link:hover{color:var(--text)}
|
||||||
|
.back-link svg{width:14px;height:14px}
|
||||||
|
.keyboard-hint{position:absolute;bottom:2rem;left:50%;transform:translateX(-50%);font-size:11px;color:var(--text-dim);display:flex;align-items:center;gap:0.5rem}
|
||||||
|
.key{padding:0.25rem 0.5rem;background:var(--bg-subtle);border:1px solid var(--border);font-size:10px}
|
||||||
|
.color-picker{margin-bottom:2rem}
|
||||||
|
.color-options{display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1.5rem}
|
||||||
|
.color-option{width:48px;height:48px;border:2px solid transparent;background:var(--color);cursor:pointer;transition:all 0.2s ease;position:relative}
|
||||||
|
.color-option:hover{transform:scale(1.1)}
|
||||||
|
.color-option.selected{border-color:var(--text);box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--color)}
|
||||||
|
.color-option.selected::after{content:'✓';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:white;font-size:18px;text-shadow:0 1px 2px rgba(0,0,0,0.3)}
|
||||||
|
.color-preview{display:flex;align-items:center;gap:1rem}
|
||||||
|
.preview-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.1em}
|
||||||
|
.preview-box{flex:1;height:8px;background:var(--bg-subtle);border:1px solid var(--border);overflow:hidden}
|
||||||
|
.preview-accent{height:100%;width:60%;background:var(--emerald);transition:background 0.3s ease,width 0.3s ease}
|
||||||
|
@media(max-width:900px){.layout{grid-template-columns:1fr}.sidebar{display:none}.main{padding:1.5rem}.back-link{position:relative;top:auto;left:auto;margin-bottom:2rem}.keyboard-hint{display:none}}
|
||||||
|
@media(max-width:480px){.step-title{font-size:1.5rem}.auth-btn{padding:0.875rem 1.25rem}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="logo">WriteKit</div>
|
||||||
|
<div class="tagline">Blogging Platform</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div class="step-indicator">
|
||||||
|
<div class="step-item active" data-step="1"><span class="step-dot"></span><span>Sign in</span></div>
|
||||||
|
<div class="step-item" data-step="2"><span class="step-dot"></span><span>Personalize</span></div>
|
||||||
|
<div class="step-item" data-step="3"><span class="step-dot"></span><span>Choose subdomain</span></div>
|
||||||
|
<div class="step-item" data-step="4"><span class="step-dot"></span><span>Launch</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="env-badge">ALPHA</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="main">
|
||||||
|
<a href="/" class="back-link">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||||
|
Back to home
|
||||||
|
</a>
|
||||||
|
<div class="step-container">
|
||||||
|
<div class="step active" id="step-auth">
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="step-label">Step 1</div>
|
||||||
|
<h1 class="step-title">Start your blog</h1>
|
||||||
|
<p class="step-desc">Sign in to create your WriteKit instance. Your blog will be ready in seconds.</p>
|
||||||
|
</div>
|
||||||
|
<div class="auth-buttons">
|
||||||
|
<a href="/auth/github?callback=/signup/complete" class="auth-btn primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
Continue with GitHub
|
||||||
|
</a>
|
||||||
|
<div class="auth-divider"><span>or</span></div>
|
||||||
|
<a href="/auth/google?callback=/signup/complete" class="auth-btn">
|
||||||
|
<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||||
|
Continue with Google
|
||||||
|
</a>
|
||||||
|
<a href="/auth/discord?callback=/signup/complete" class="auth-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||||
|
Continue with Discord
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step" id="step-personalize">
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="user-greeting">
|
||||||
|
<img class="user-avatar" id="personalize-avatar" alt=""/>
|
||||||
|
<div class="step-label">Step 2</div>
|
||||||
|
<h1 class="step-title" id="personalize-title">Pick your style</h1>
|
||||||
|
</div>
|
||||||
|
<p class="step-desc">Choose an accent color for your blog. You can change this anytime in settings.</p>
|
||||||
|
</div>
|
||||||
|
<div class="color-picker">
|
||||||
|
<div class="color-options">
|
||||||
|
<button type="button" class="color-option" data-color="#10b981" style="--color:#10b981" title="Emerald"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#06b6d4" style="--color:#06b6d4" title="Cyan"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#8b5cf6" style="--color:#8b5cf6" title="Violet"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#ec4899" style="--color:#ec4899" title="Pink"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#f97316" style="--color:#f97316" title="Orange"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#eab308" style="--color:#eab308" title="Yellow"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#ef4444" style="--color:#ef4444" title="Red"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#64748b" style="--color:#64748b" title="Slate"></button>
|
||||||
|
</div>
|
||||||
|
<div class="color-preview">
|
||||||
|
<span class="preview-label">Preview</span>
|
||||||
|
<div class="preview-box" id="color-preview"><div class="preview-accent"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-back" id="btn-personalize-back" type="button" title="Use different account">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn primary" id="btn-personalize-next">Continue</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step" id="step-subdomain">
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="user-greeting" id="user-greeting">
|
||||||
|
<img class="user-avatar" id="user-avatar" alt=""/>
|
||||||
|
<div class="step-label">Step 3</div>
|
||||||
|
<h1 class="step-title" id="greeting-title">Choose your subdomain</h1>
|
||||||
|
</div>
|
||||||
|
<p class="step-desc">Pick a memorable address for your blog. You can add a custom domain later.</p>
|
||||||
|
</div>
|
||||||
|
<div class="subdomain-form">
|
||||||
|
<div class="input-row" id="input-row">
|
||||||
|
<input type="text" class="subdomain-input" id="subdomain" placeholder="myblog" autocomplete="off" spellcheck="false" autofocus/>
|
||||||
|
<span class="subdomain-suffix" id="domain-suffix">.writekit.dev</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-status" id="status"></div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-back" id="btn-back" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn primary" id="btn-launch" disabled>Create my blog</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step" id="step-provisioning">
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="step-label">Step 4</div>
|
||||||
|
<h1 class="step-title">Launching your blog</h1>
|
||||||
|
<p class="step-desc">Setting everything up. This only takes a few seconds.</p>
|
||||||
|
</div>
|
||||||
|
<div class="progress-steps" id="progress-steps">
|
||||||
|
<div class="progress-step" data-step="1"><span class="dot"></span><span>Reserving subdomain...</span></div>
|
||||||
|
<div class="progress-step" data-step="2"><span class="dot"></span><span>Spinning up container...</span></div>
|
||||||
|
<div class="progress-step" data-step="3"><span class="dot"></span><span>Configuring SSL...</span></div>
|
||||||
|
<div class="progress-step" data-step="4"><span class="dot"></span><span>Almost ready...</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step" id="step-success">
|
||||||
|
<div class="success-content">
|
||||||
|
<div class="success-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="step-title">You're all set!</div>
|
||||||
|
<div class="success-url" id="success-url"></div>
|
||||||
|
<div class="success-redirect"><span class="dot"></span><span>Redirecting to your studio...</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="keyboard-hint">Press <span class="key">Enter</span> to continue</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const $ = s => document.getElementById(s)
|
||||||
|
const $$ = s => document.querySelectorAll(s)
|
||||||
|
|
||||||
|
const steps = { auth: $('step-auth'), personalize: $('step-personalize'), subdomain: $('step-subdomain'), provisioning: $('step-provisioning'), success: $('step-success') }
|
||||||
|
const stepIndicators = $$('.step-item')
|
||||||
|
const subdomainInput = $('subdomain')
|
||||||
|
const inputRow = $('input-row')
|
||||||
|
const status = $('status')
|
||||||
|
const btnLaunch = $('btn-launch')
|
||||||
|
const btnBack = $('btn-back')
|
||||||
|
const successUrl = $('success-url')
|
||||||
|
const userAvatar = $('user-avatar')
|
||||||
|
const greetingTitle = $('greeting-title')
|
||||||
|
const personalizeAvatar = $('personalize-avatar')
|
||||||
|
const personalizeTitle = $('personalize-title')
|
||||||
|
const colorOptions = $$('.color-option')
|
||||||
|
const previewAccent = document.querySelector('.preview-accent')
|
||||||
|
const btnPersonalizeNext = $('btn-personalize-next')
|
||||||
|
const btnPersonalizeBack = $('btn-personalize-back')
|
||||||
|
|
||||||
|
let currentStep = 'auth'
|
||||||
|
let currentSubdomain = ''
|
||||||
|
let isAvailable = false
|
||||||
|
let debounceTimer
|
||||||
|
let currentUser = null
|
||||||
|
let selectedColor = '#10b981'
|
||||||
|
|
||||||
|
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const fetchUserInfo = async token => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/auth/user?token=${encodeURIComponent(token)}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
sessionStorage.removeItem('signup_token')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const user = await res.json()
|
||||||
|
currentUser = user
|
||||||
|
const firstName = user.name?.split(' ')[0] ?? ''
|
||||||
|
if (user.avatar_url) {
|
||||||
|
userAvatar.src = user.avatar_url
|
||||||
|
userAvatar.onload = () => userAvatar.classList.add('loaded')
|
||||||
|
personalizeAvatar.src = user.avatar_url
|
||||||
|
personalizeAvatar.onload = () => personalizeAvatar.classList.add('loaded')
|
||||||
|
}
|
||||||
|
if (firstName) {
|
||||||
|
greetingTitle.textContent = `Hey ${firstName}!`
|
||||||
|
personalizeTitle.textContent = `Pick your style, ${firstName}`
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem('signup_token')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToStep = stepName => {
|
||||||
|
const currentEl = steps[currentStep]
|
||||||
|
const nextEl = steps[stepName]
|
||||||
|
const stepOrder = ['auth', 'personalize', 'subdomain', 'provisioning', 'success']
|
||||||
|
const nextIndex = stepOrder.indexOf(stepName)
|
||||||
|
|
||||||
|
stepIndicators.forEach((indicator, i) => {
|
||||||
|
indicator.classList.remove('active', 'completed')
|
||||||
|
if (i < nextIndex) indicator.classList.add('completed')
|
||||||
|
else if (i === nextIndex) indicator.classList.add('active')
|
||||||
|
})
|
||||||
|
|
||||||
|
currentEl.classList.add('exit-up')
|
||||||
|
currentEl.classList.remove('active')
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
currentEl.classList.remove('exit-up')
|
||||||
|
nextEl.classList.add('active')
|
||||||
|
currentStep = stepName
|
||||||
|
if (stepName === 'subdomain') setTimeout(() => subdomainInput.focus(), 100)
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAvailability = async subdomain => {
|
||||||
|
if (subdomain !== currentSubdomain) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/demo/check?subdomain=${encodeURIComponent(subdomain)}`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (subdomain !== currentSubdomain) return
|
||||||
|
if (data.domain) $('domain-suffix').textContent = '.' + data.domain
|
||||||
|
if (data.available) {
|
||||||
|
status.textContent = `${subdomain}.${data.domain} is available`
|
||||||
|
status.className = 'input-status available'
|
||||||
|
inputRow.className = 'input-row valid'
|
||||||
|
btnLaunch.disabled = false
|
||||||
|
isAvailable = true
|
||||||
|
} else {
|
||||||
|
status.textContent = data.reason || 'Not available'
|
||||||
|
status.className = 'input-status unavailable'
|
||||||
|
inputRow.className = 'input-row invalid'
|
||||||
|
btnLaunch.disabled = true
|
||||||
|
isAvailable = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
status.textContent = 'Error checking availability'
|
||||||
|
status.className = 'input-status unavailable'
|
||||||
|
btnLaunch.disabled = true
|
||||||
|
isAvailable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const animateProgress = async url => {
|
||||||
|
const progressSteps = $$('#progress-steps .progress-step')
|
||||||
|
let ready = false
|
||||||
|
let redirecting = false
|
||||||
|
|
||||||
|
const pollUrl = async () => {
|
||||||
|
const start = Date.now()
|
||||||
|
while (!ready && Date.now() - start < 30000) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url + '/health', { method: 'HEAD' })
|
||||||
|
if (res.ok || res.status === 307 || res.status === 302) ready = true
|
||||||
|
} catch { await sleep(300) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doRedirect = () => {
|
||||||
|
if (redirecting) return
|
||||||
|
redirecting = true
|
||||||
|
progressSteps.forEach(s => { s.classList.remove('active'); s.classList.add('done') })
|
||||||
|
successUrl.textContent = url.replace('https://', '')
|
||||||
|
goToStep('success')
|
||||||
|
const token = sessionStorage.getItem('signup_token')
|
||||||
|
setTimeout(() => { window.location.href = url + '/auth/callback?token=' + encodeURIComponent(token) + '&redirect=/studio' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
pollUrl().then(() => { if (ready) doRedirect() })
|
||||||
|
|
||||||
|
for (let i = 0; i < progressSteps.length; i++) {
|
||||||
|
if (ready) return doRedirect()
|
||||||
|
progressSteps[i].classList.add('active')
|
||||||
|
await sleep(300)
|
||||||
|
progressSteps[i].classList.remove('active')
|
||||||
|
progressSteps[i].classList.add('done')
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!ready) await sleep(100)
|
||||||
|
doRedirect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchBlog = async () => {
|
||||||
|
if (!isAvailable || !currentSubdomain) return
|
||||||
|
const token = sessionStorage.getItem('signup_token')
|
||||||
|
if (!token) { goToStep('auth'); return }
|
||||||
|
|
||||||
|
btnLaunch.disabled = true
|
||||||
|
goToStep('provisioning')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/signup/tenant', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ subdomain: currentSubdomain, accent_color: selectedColor })
|
||||||
|
})
|
||||||
|
if (res.status === 401) { sessionStorage.removeItem('signup_token'); goToStep('auth'); return }
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok && data.url) {
|
||||||
|
await animateProgress(data.url)
|
||||||
|
} else {
|
||||||
|
goToStep('subdomain')
|
||||||
|
status.textContent = data.error || 'Failed to create blog'
|
||||||
|
status.className = 'input-status unavailable'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
goToStep('subdomain')
|
||||||
|
status.textContent = 'Error creating blog'
|
||||||
|
status.className = 'input-status unavailable'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
const urlToken = urlParams.get('token')
|
||||||
|
const storedToken = sessionStorage.getItem('signup_token')
|
||||||
|
|
||||||
|
if (urlToken) {
|
||||||
|
sessionStorage.setItem('signup_token', urlToken)
|
||||||
|
window.history.replaceState({}, '', '/signup')
|
||||||
|
if (await fetchUserInfo(urlToken)) goToStep('personalize')
|
||||||
|
} else if (storedToken) {
|
||||||
|
if (await fetchUserInfo(storedToken)) goToStep('personalize')
|
||||||
|
}
|
||||||
|
|
||||||
|
colorOptions[0]?.classList.add('selected')
|
||||||
|
})()
|
||||||
|
|
||||||
|
subdomainInput.addEventListener('input', () => {
|
||||||
|
const value = subdomainInput.value.toLowerCase().replace(/[^a-z0-9-]/g, '')
|
||||||
|
if (value !== subdomainInput.value) subdomainInput.value = value
|
||||||
|
currentSubdomain = value
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
status.textContent = ''
|
||||||
|
status.className = 'input-status'
|
||||||
|
inputRow.className = 'input-row'
|
||||||
|
btnLaunch.disabled = true
|
||||||
|
isAvailable = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
status.innerHTML = '<span class="dot"></span> Checking...'
|
||||||
|
status.className = 'input-status checking'
|
||||||
|
inputRow.className = 'input-row'
|
||||||
|
btnLaunch.disabled = true
|
||||||
|
debounceTimer = setTimeout(() => checkAvailability(value), 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
colorOptions.forEach(option => {
|
||||||
|
option.addEventListener('click', () => {
|
||||||
|
colorOptions.forEach(o => o.classList.remove('selected'))
|
||||||
|
option.classList.add('selected')
|
||||||
|
selectedColor = option.dataset.color
|
||||||
|
previewAccent.style.background = selectedColor
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
btnPersonalizeBack.addEventListener('click', () => { sessionStorage.removeItem('signup_token'); goToStep('auth') })
|
||||||
|
btnPersonalizeNext.addEventListener('click', () => goToStep('subdomain'))
|
||||||
|
btnBack.addEventListener('click', () => goToStep('personalize'))
|
||||||
|
btnLaunch.addEventListener('click', launchBlog)
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Enter' && currentStep === 'subdomain' && isAvailable) launchBlog() })
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
113
internal/storage/s3.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type S3Client struct {
|
||||||
|
s3 *s3.Client
|
||||||
|
presign *s3.PresignClient
|
||||||
|
bucket string
|
||||||
|
publicURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type S3Config struct {
|
||||||
|
Endpoint string
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
Bucket string
|
||||||
|
PublicURL string
|
||||||
|
Region string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewS3Client(cfg S3Config) (*S3Client, error) {
|
||||||
|
awsCfg, err := config.LoadDefaultConfig(context.Background(),
|
||||||
|
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
|
||||||
|
config.WithRegion(cfg.Region),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||||
|
if cfg.Endpoint != "" {
|
||||||
|
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &S3Client{
|
||||||
|
s3: client,
|
||||||
|
presign: s3.NewPresignClient(client),
|
||||||
|
bucket: cfg.Bucket,
|
||||||
|
publicURL: cfg.PublicURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewR2Client() (*S3Client, error) {
|
||||||
|
accountID := os.Getenv("R2_ACCOUNT_ID")
|
||||||
|
accessKey := os.Getenv("R2_ACCESS_KEY_ID")
|
||||||
|
secretKey := os.Getenv("R2_SECRET_ACCESS_KEY")
|
||||||
|
bucket := os.Getenv("R2_BUCKET")
|
||||||
|
publicURL := os.Getenv("R2_PUBLIC_URL")
|
||||||
|
|
||||||
|
if accountID == "" || accessKey == "" || secretKey == "" {
|
||||||
|
return nil, fmt.Errorf("R2 credentials not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := os.Getenv("R2_ENDPOINT")
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewS3Client(S3Config{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
AccessKey: accessKey,
|
||||||
|
SecretKey: secretKey,
|
||||||
|
Bucket: bucket,
|
||||||
|
PublicURL: publicURL,
|
||||||
|
Region: "auto",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *S3Client) Upload(ctx context.Context, key string, body io.Reader, contentType string) error {
|
||||||
|
_, err := c.s3.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(c.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
Body: body,
|
||||||
|
ContentType: aws.String(contentType),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *S3Client) Delete(ctx context.Context, key string) error {
|
||||||
|
_, err := c.s3.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(c.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *S3Client) PresignUpload(ctx context.Context, key string, contentType string, expires time.Duration) (string, error) {
|
||||||
|
req, err := c.presign.PresignPutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(c.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
ContentType: aws.String(contentType),
|
||||||
|
}, s3.WithPresignExpires(expires))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return req.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *S3Client) PublicURL(key string) string {
|
||||||
|
return fmt.Sprintf("%s/%s", c.publicURL, key)
|
||||||
|
}
|
||||||
14
internal/storage/storage.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
Upload(ctx context.Context, key string, body io.Reader, contentType string) error
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
|
PresignUpload(ctx context.Context, key string, contentType string, expires time.Duration) (string, error)
|
||||||
|
PublicURL(key string) string
|
||||||
|
}
|
||||||
279
internal/tenant/analytics.go
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PageView struct {
|
||||||
|
ID int64
|
||||||
|
Path string
|
||||||
|
PostSlug string
|
||||||
|
Referrer string
|
||||||
|
UserAgent string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalyticsSummary struct {
|
||||||
|
TotalViews int64 `json:"total_views"`
|
||||||
|
TotalPageViews int64 `json:"total_page_views"`
|
||||||
|
UniqueVisitors int64 `json:"unique_visitors"`
|
||||||
|
TotalBandwidth int64 `json:"total_bandwidth"`
|
||||||
|
ViewsChange float64 `json:"views_change"`
|
||||||
|
TopPages []PageStats `json:"top_pages"`
|
||||||
|
TopReferrers []ReferrerStats `json:"top_referrers"`
|
||||||
|
ViewsByDay []DailyStats `json:"views_by_day"`
|
||||||
|
Browsers []NamedStat `json:"browsers"`
|
||||||
|
OS []NamedStat `json:"os"`
|
||||||
|
Devices []NamedStat `json:"devices"`
|
||||||
|
Countries []NamedStat `json:"countries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageStats struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Views int64 `json:"views"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReferrerStats struct {
|
||||||
|
Referrer string `json:"referrer"`
|
||||||
|
Views int64 `json:"views"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyStats struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Views int64 `json:"views"`
|
||||||
|
Visitors int64 `json:"visitors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NamedStat struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchivedDay struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
PageViews int64 `json:"page_views"`
|
||||||
|
UniqueVisitors int64 `json:"unique_visitors"`
|
||||||
|
Bandwidth int64 `json:"bandwidth"`
|
||||||
|
Browsers []NamedStat `json:"browsers"`
|
||||||
|
OS []NamedStat `json:"os"`
|
||||||
|
Devices []NamedStat `json:"devices"`
|
||||||
|
Countries []NamedStat `json:"countries"`
|
||||||
|
Paths []PageStats `json:"paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RecordPageView(ctx context.Context, path, postSlug, referrer, userAgent string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO page_views (path, post_slug, referrer, user_agent, visitor_hash, utm_source, utm_medium, utm_campaign, device_type, browser, os, country)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
path, nullStr(postSlug), nullStr(referrer), nullStr(userAgent), sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAnalytics(ctx context.Context, days int) (*AnalyticsSummary, error) {
|
||||||
|
if days <= 0 {
|
||||||
|
days = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||||
|
|
||||||
|
var totalCount, uniqueCount int64
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*), COUNT(DISTINCT visitor_hash) FROM page_views WHERE created_at >= ?`, since).
|
||||||
|
Scan(&totalCount, &uniqueCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
topPagesRows, err := q.db.QueryContext(ctx, `SELECT path, COUNT(*) as views FROM page_views WHERE created_at >= ? GROUP BY path ORDER BY views DESC LIMIT ?`, since, 10)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer topPagesRows.Close()
|
||||||
|
|
||||||
|
var topPages []PageStats
|
||||||
|
for topPagesRows.Next() {
|
||||||
|
var p PageStats
|
||||||
|
if err := topPagesRows.Scan(&p.Path, &p.Views); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
topPages = append(topPages, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
topRefRows, err := q.db.QueryContext(ctx, `SELECT COALESCE(referrer, 'Direct') as referrer, COUNT(*) as views FROM page_views WHERE created_at >= ? AND referrer != '' GROUP BY referrer ORDER BY views DESC LIMIT ?`, since, 10)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer topRefRows.Close()
|
||||||
|
|
||||||
|
var topReferrers []ReferrerStats
|
||||||
|
for topRefRows.Next() {
|
||||||
|
var r ReferrerStats
|
||||||
|
if err := topRefRows.Scan(&r.Referrer, &r.Views); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
topReferrers = append(topReferrers, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewsByDayRows, err := q.db.QueryContext(ctx, `SELECT DATE(created_at) as date, COUNT(*) as views FROM page_views WHERE created_at >= ? GROUP BY date ORDER BY date ASC`, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer viewsByDayRows.Close()
|
||||||
|
|
||||||
|
var viewsByDay []DailyStats
|
||||||
|
for viewsByDayRows.Next() {
|
||||||
|
var d DailyStats
|
||||||
|
var date any
|
||||||
|
if err := viewsByDayRows.Scan(&date, &d.Views); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s, ok := date.(string); ok {
|
||||||
|
d.Date = s
|
||||||
|
}
|
||||||
|
viewsByDay = append(viewsByDay, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AnalyticsSummary{
|
||||||
|
TotalViews: totalCount,
|
||||||
|
TotalPageViews: totalCount,
|
||||||
|
UniqueVisitors: uniqueCount,
|
||||||
|
TopPages: topPages,
|
||||||
|
TopReferrers: topReferrers,
|
||||||
|
ViewsByDay: viewsByDay,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetPostAnalytics(ctx context.Context, slug string, days int) (*AnalyticsSummary, error) {
|
||||||
|
if days <= 0 {
|
||||||
|
days = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||||
|
|
||||||
|
var totalViews int64
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM page_views WHERE post_slug = ? AND created_at >= ?`, slug, since).Scan(&totalViews)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
viewsByDayRows, err := q.db.QueryContext(ctx, `SELECT DATE(created_at) as date, COUNT(*) as views FROM page_views WHERE post_slug = ? AND created_at >= ? GROUP BY date ORDER BY date ASC`, slug, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer viewsByDayRows.Close()
|
||||||
|
|
||||||
|
var viewsByDay []DailyStats
|
||||||
|
for viewsByDayRows.Next() {
|
||||||
|
var d DailyStats
|
||||||
|
var date any
|
||||||
|
if err := viewsByDayRows.Scan(&date, &d.Views); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s, ok := date.(string); ok {
|
||||||
|
d.Date = s
|
||||||
|
}
|
||||||
|
viewsByDay = append(viewsByDay, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
refRows, err := q.db.QueryContext(ctx, `SELECT COALESCE(referrer, 'Direct') as referrer, COUNT(*) as views FROM page_views WHERE post_slug = ? AND created_at >= ? AND referrer != '' GROUP BY referrer ORDER BY views DESC LIMIT ?`, slug, since, 10)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer refRows.Close()
|
||||||
|
|
||||||
|
var topReferrers []ReferrerStats
|
||||||
|
for refRows.Next() {
|
||||||
|
var r ReferrerStats
|
||||||
|
if err := refRows.Scan(&r.Referrer, &r.Views); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
topReferrers = append(topReferrers, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AnalyticsSummary{
|
||||||
|
TotalViews: totalViews,
|
||||||
|
TopReferrers: topReferrers,
|
||||||
|
ViewsByDay: viewsByDay,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SaveDailyAnalytics(ctx context.Context, day *ArchivedDay) error {
|
||||||
|
browsers, _ := json.Marshal(day.Browsers)
|
||||||
|
os, _ := json.Marshal(day.OS)
|
||||||
|
devices, _ := json.Marshal(day.Devices)
|
||||||
|
countries, _ := json.Marshal(day.Countries)
|
||||||
|
paths, _ := json.Marshal(day.Paths)
|
||||||
|
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO daily_analytics (date, requests, page_views, unique_visitors, bandwidth, browsers, os, devices, countries, paths)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(date) DO UPDATE SET
|
||||||
|
requests = excluded.requests, page_views = excluded.page_views, unique_visitors = excluded.unique_visitors,
|
||||||
|
bandwidth = excluded.bandwidth, browsers = excluded.browsers, os = excluded.os, devices = excluded.devices,
|
||||||
|
countries = excluded.countries, paths = excluded.paths`,
|
||||||
|
day.Date, day.Requests, day.PageViews, day.UniqueVisitors, day.Bandwidth,
|
||||||
|
nullStr(string(browsers)), nullStr(string(os)), nullStr(string(devices)), nullStr(string(countries)), nullStr(string(paths)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetArchivedAnalytics(ctx context.Context, since, until string) ([]ArchivedDay, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT date, requests, page_views, unique_visitors, bandwidth, browsers, os, devices, countries, paths
|
||||||
|
FROM daily_analytics WHERE date >= ? AND date <= ? ORDER BY date ASC`, since, until)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var days []ArchivedDay
|
||||||
|
for rows.Next() {
|
||||||
|
var d ArchivedDay
|
||||||
|
var requests, pageViews, uniqueVisitors, bandwidth sql.NullInt64
|
||||||
|
var browsers, os, devices, countries, paths sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(&d.Date, &requests, &pageViews, &uniqueVisitors, &bandwidth, &browsers, &os, &devices, &countries, &paths); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Requests = requests.Int64
|
||||||
|
d.PageViews = pageViews.Int64
|
||||||
|
d.UniqueVisitors = uniqueVisitors.Int64
|
||||||
|
d.Bandwidth = bandwidth.Int64
|
||||||
|
|
||||||
|
if browsers.Valid {
|
||||||
|
json.Unmarshal([]byte(browsers.String), &d.Browsers)
|
||||||
|
}
|
||||||
|
if os.Valid {
|
||||||
|
json.Unmarshal([]byte(os.String), &d.OS)
|
||||||
|
}
|
||||||
|
if devices.Valid {
|
||||||
|
json.Unmarshal([]byte(devices.String), &d.Devices)
|
||||||
|
}
|
||||||
|
if countries.Valid {
|
||||||
|
json.Unmarshal([]byte(countries.String), &d.Countries)
|
||||||
|
}
|
||||||
|
if paths.Valid {
|
||||||
|
json.Unmarshal([]byte(paths.String), &d.Paths)
|
||||||
|
}
|
||||||
|
days = append(days, d)
|
||||||
|
}
|
||||||
|
return days, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetOldestArchivedDate(ctx context.Context) (string, error) {
|
||||||
|
var date any
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT MIN(date) FROM daily_analytics`).Scan(&date)
|
||||||
|
if err != nil || date == nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if s, ok := date.(string); ok {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) HasArchivedDate(ctx context.Context, date string) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM daily_analytics WHERE date = ?`, date).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
94
internal/tenant/apikeys.go
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queries) ListAPIKeys(ctx context.Context) ([]APIKey, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT key, name, created_at, last_used_at FROM api_keys ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var keys []APIKey
|
||||||
|
for rows.Next() {
|
||||||
|
k, err := scanAPIKey(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateAPIKey(ctx context.Context, name string) (*APIKey, error) {
|
||||||
|
key, err := generateAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err = q.db.ExecContext(ctx, `INSERT INTO api_keys (key, name, created_at) VALUES (?, ?, ?)`, key, name, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &APIKey{
|
||||||
|
Key: key,
|
||||||
|
Name: name,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ValidateAPIKey(ctx context.Context, key string) (bool, error) {
|
||||||
|
var dummy int64
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT 1 FROM api_keys WHERE key = ?`, key).Scan(&dummy)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
q.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE key = ?`, now, key)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAPIKey(ctx context.Context, key string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM api_keys WHERE key = ?`, key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAPIKey(s scanner) (APIKey, error) {
|
||||||
|
var k APIKey
|
||||||
|
var createdAt, lastUsedAt sql.NullString
|
||||||
|
|
||||||
|
err := s.Scan(&k.Key, &k.Name, &createdAt, &lastUsedAt)
|
||||||
|
if err != nil {
|
||||||
|
return k, err
|
||||||
|
}
|
||||||
|
|
||||||
|
k.CreatedAt = parseTime(createdAt.String)
|
||||||
|
if lastUsedAt.Valid {
|
||||||
|
t := parseTime(lastUsedAt.String)
|
||||||
|
k.LastUsedAt = &t
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateAPIKey() (string, error) {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "wk_" + hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
80
internal/tenant/assets.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queries) ListAssets(ctx context.Context) ([]Asset, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT id, filename, r2_key, content_type, size, width, height, created_at
|
||||||
|
FROM assets ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var assets []Asset
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAsset(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assets = append(assets, a)
|
||||||
|
}
|
||||||
|
return assets, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAsset(ctx context.Context, id string) (*Asset, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT id, filename, r2_key, content_type, size, width, height, created_at
|
||||||
|
FROM assets WHERE id = ?`, id)
|
||||||
|
|
||||||
|
a, err := scanAsset(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateAsset(ctx context.Context, a *Asset) error {
|
||||||
|
if a.ID == "" {
|
||||||
|
a.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO assets (id, filename, r2_key, content_type, size, width, height, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
a.ID, a.Filename, a.R2Key, nullStr(a.ContentType), nullInt64(a.Size), nullInt64(int64(a.Width)), nullInt64(int64(a.Height)), now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAsset(ctx context.Context, id string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM assets WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAsset(s scanner) (Asset, error) {
|
||||||
|
var a Asset
|
||||||
|
var contentType, createdAt sql.NullString
|
||||||
|
var size, width, height sql.NullInt64
|
||||||
|
|
||||||
|
err := s.Scan(&a.ID, &a.Filename, &a.R2Key, &contentType, &size, &width, &height, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ContentType = contentType.String
|
||||||
|
a.Size = size.Int64
|
||||||
|
a.Width = int(width.Int64)
|
||||||
|
a.Height = int(height.Int64)
|
||||||
|
a.CreatedAt = parseTime(createdAt.String)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullInt64(v int64) sql.NullInt64 {
|
||||||
|
return sql.NullInt64{Int64: v, Valid: v != 0}
|
||||||
|
}
|
||||||
70
internal/tenant/comments.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queries) ListComments(ctx context.Context, postSlug string) ([]Comment, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT id, user_id, post_slug, content, content_html, parent_id, created_at, updated_at
|
||||||
|
FROM comments WHERE post_slug = ? ORDER BY created_at ASC`, postSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var comments []Comment
|
||||||
|
for rows.Next() {
|
||||||
|
c, err := scanComment(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
comments = append(comments, c)
|
||||||
|
}
|
||||||
|
return comments, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetComment(ctx context.Context, id int64) (*Comment, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT id, user_id, post_slug, content, content_html, parent_id, created_at, updated_at
|
||||||
|
FROM comments WHERE id = ?`, id)
|
||||||
|
|
||||||
|
c, err := scanComment(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateComment(ctx context.Context, c *Comment) error {
|
||||||
|
result, err := q.db.ExecContext(ctx, `INSERT INTO comments (user_id, post_slug, content, content_html, parent_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
c.UserID, c.PostSlug, c.Content, nullStr(c.ContentHTML), c.ParentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.ID, _ = result.LastInsertId()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteComment(ctx context.Context, id int64) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM comments WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanComment(s scanner) (Comment, error) {
|
||||||
|
var c Comment
|
||||||
|
var contentHTML, createdAt, updatedAt sql.NullString
|
||||||
|
|
||||||
|
err := s.Scan(&c.ID, &c.UserID, &c.PostSlug, &c.Content, &contentHTML, &c.ParentID, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ContentHTML = contentHTML.String
|
||||||
|
c.CreatedAt = parseTime(createdAt.String)
|
||||||
|
c.UpdatedAt = parseTime(updatedAt.String)
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
63
internal/tenant/members.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queries) UpsertMember(ctx context.Context, m *Member) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO members (user_id, email, name, tier, status, expires_at, synced_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
email = excluded.email, name = excluded.name, tier = excluded.tier,
|
||||||
|
status = excluded.status, expires_at = excluded.expires_at, synced_at = CURRENT_TIMESTAMP`,
|
||||||
|
m.UserID, m.Email, nullStr(m.Name), m.Tier, m.Status, timeToStr(m.ExpiresAt))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetMember(ctx context.Context, userID string) (*Member, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT user_id, email, name, tier, status, expires_at, synced_at
|
||||||
|
FROM members WHERE user_id = ?`, userID)
|
||||||
|
|
||||||
|
m, err := scanMember(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) IsMember(ctx context.Context, userID string) bool {
|
||||||
|
var count int64
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM members
|
||||||
|
WHERE user_id = ? AND status = 'active'
|
||||||
|
AND (expires_at IS NULL OR expires_at > datetime('now'))`, userID).Scan(&count)
|
||||||
|
return err == nil && count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteMember(ctx context.Context, userID string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM members WHERE user_id = ?`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanMember(s scanner) (Member, error) {
|
||||||
|
var m Member
|
||||||
|
var name, expiresAt, syncedAt sql.NullString
|
||||||
|
|
||||||
|
err := s.Scan(&m.UserID, &m.Email, &name, &m.Tier, &m.Status, &expiresAt, &syncedAt)
|
||||||
|
if err != nil {
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Name = name.String
|
||||||
|
m.SyncedAt = parseTime(syncedAt.String)
|
||||||
|
if expiresAt.Valid {
|
||||||
|
if t, err := time.Parse(time.RFC3339, expiresAt.String); err == nil {
|
||||||
|
m.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
124
internal/tenant/models.go
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
ID string
|
||||||
|
Slug string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Tags []string
|
||||||
|
CoverImage string
|
||||||
|
ContentMD string
|
||||||
|
ContentHTML string
|
||||||
|
IsPublished bool
|
||||||
|
MembersOnly bool
|
||||||
|
PublishedAt *time.Time
|
||||||
|
UpdatedAt *time.Time
|
||||||
|
Aliases []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ModifiedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostDraft struct {
|
||||||
|
PostID string
|
||||||
|
Slug string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Tags []string
|
||||||
|
CoverImage string
|
||||||
|
MembersOnly bool
|
||||||
|
ContentMD string
|
||||||
|
ContentHTML string
|
||||||
|
ModifiedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostVersion struct {
|
||||||
|
ID int64
|
||||||
|
PostID string
|
||||||
|
Slug string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Tags []string
|
||||||
|
CoverImage string
|
||||||
|
ContentMD string
|
||||||
|
ContentHTML string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Asset struct {
|
||||||
|
ID string
|
||||||
|
Filename string
|
||||||
|
R2Key string
|
||||||
|
ContentType string
|
||||||
|
Size int64
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Settings map[string]string
|
||||||
|
|
||||||
|
type Member struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Tier string
|
||||||
|
Status string
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
SyncedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Comment struct {
|
||||||
|
ID int64
|
||||||
|
UserID string
|
||||||
|
PostSlug string
|
||||||
|
Content string
|
||||||
|
ContentHTML string
|
||||||
|
ParentID *int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Reaction struct {
|
||||||
|
ID int64
|
||||||
|
UserID string
|
||||||
|
AnonID string
|
||||||
|
PostSlug string
|
||||||
|
Emoji string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
AvatarURL string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Token string
|
||||||
|
UserID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKey struct {
|
||||||
|
Key string
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastUsedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Language string
|
||||||
|
Source string
|
||||||
|
Wasm []byte
|
||||||
|
WasmSize int
|
||||||
|
Hooks []string
|
||||||
|
Enabled bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
39
internal/tenant/pages.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queries) GetPage(ctx context.Context, path string) ([]byte, string, error) {
|
||||||
|
var html []byte
|
||||||
|
var etag sql.NullString
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT html, etag FROM pages WHERE path = ?`, path).Scan(&html, &etag)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return html, etag.String, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetPage(ctx context.Context, path string, html []byte, etag string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO pages (path, html, etag, built_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET html = excluded.html, etag = excluded.etag, built_at = excluded.built_at`,
|
||||||
|
path, html, nullStr(etag), now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAllPages(ctx context.Context) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM pages`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeletePage(ctx context.Context, path string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM pages WHERE path = ?`, path)
|
||||||
|
return err
|
||||||
|
}
|
||||||
136
internal/tenant/plugins.go
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queries) CountPlugins(ctx context.Context) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM plugins`).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListPlugins(ctx context.Context) ([]Plugin, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT id, name, language, source, hooks, enabled, LENGTH(wasm) as wasm_size, created_at, updated_at
|
||||||
|
FROM plugins ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var plugins []Plugin
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanPluginList(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plugins = append(plugins, p)
|
||||||
|
}
|
||||||
|
return plugins, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetPlugin(ctx context.Context, id string) (*Plugin, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT id, name, language, source, wasm, hooks, enabled, created_at, updated_at
|
||||||
|
FROM plugins WHERE id = ?`, id)
|
||||||
|
|
||||||
|
p, err := scanPlugin(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreatePlugin(ctx context.Context, p *Plugin) error {
|
||||||
|
hooks, _ := json.Marshal(p.Hooks)
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO plugins (id, name, language, source, wasm, hooks, enabled)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
p.ID, p.Name, p.Language, p.Source, p.Wasm, string(hooks), boolToInt(p.Enabled))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdatePlugin(ctx context.Context, p *Plugin) error {
|
||||||
|
hooks, _ := json.Marshal(p.Hooks)
|
||||||
|
_, err := q.db.ExecContext(ctx, `UPDATE plugins SET
|
||||||
|
name = ?, language = ?, source = ?, wasm = ?, hooks = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
p.Name, p.Language, p.Source, p.Wasm, string(hooks), boolToInt(p.Enabled), p.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeletePlugin(ctx context.Context, id string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM plugins WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetPluginsByHook(ctx context.Context, hook string) ([]Plugin, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT id, name, language, source, wasm, hooks, enabled, created_at, updated_at
|
||||||
|
FROM plugins WHERE enabled = 1 AND hooks LIKE ?`, "%"+hook+"%")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var plugins []Plugin
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanPlugin(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, h := range p.Hooks {
|
||||||
|
if h == hook {
|
||||||
|
plugins = append(plugins, p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plugins, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) TogglePlugin(ctx context.Context, id string, enabled bool) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `UPDATE plugins SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
boolToInt(enabled), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPlugin(s scanner) (Plugin, error) {
|
||||||
|
var p Plugin
|
||||||
|
var hooks string
|
||||||
|
var enabled sql.NullInt64
|
||||||
|
var createdAt, updatedAt sql.NullString
|
||||||
|
|
||||||
|
err := s.Scan(&p.ID, &p.Name, &p.Language, &p.Source, &p.Wasm, &hooks, &enabled, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Enabled = enabled.Int64 == 1
|
||||||
|
p.CreatedAt = parseTime(createdAt.String)
|
||||||
|
p.UpdatedAt = parseTime(updatedAt.String)
|
||||||
|
json.Unmarshal([]byte(hooks), &p.Hooks)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPluginList(s scanner) (Plugin, error) {
|
||||||
|
var p Plugin
|
||||||
|
var hooks string
|
||||||
|
var enabled sql.NullInt64
|
||||||
|
var wasmSize sql.NullInt64
|
||||||
|
var createdAt, updatedAt sql.NullString
|
||||||
|
|
||||||
|
err := s.Scan(&p.ID, &p.Name, &p.Language, &p.Source, &hooks, &enabled, &wasmSize, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Enabled = enabled.Int64 == 1
|
||||||
|
p.WasmSize = int(wasmSize.Int64)
|
||||||
|
p.CreatedAt = parseTime(createdAt.String)
|
||||||
|
p.UpdatedAt = parseTime(updatedAt.String)
|
||||||
|
json.Unmarshal([]byte(hooks), &p.Hooks)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
176
internal/tenant/pool.go
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"database/sql"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxOpenConns = 500
|
||||||
|
cacheTTL = 5 * time.Minute
|
||||||
|
cacheCleanupFreq = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type conn struct {
|
||||||
|
db *sql.DB
|
||||||
|
tenantID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool manages SQLite connections for tenants with LRU eviction.
|
||||||
|
type Pool struct {
|
||||||
|
dataDir string
|
||||||
|
mu sync.Mutex
|
||||||
|
conns map[string]*list.Element
|
||||||
|
lru *list.List
|
||||||
|
inMemory map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPool(dataDir string) *Pool {
|
||||||
|
return &Pool{
|
||||||
|
dataDir: dataDir,
|
||||||
|
conns: make(map[string]*list.Element),
|
||||||
|
lru: list.New(),
|
||||||
|
inMemory: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) MarkAsDemo(tenantID string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.inMemory[tenantID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) Get(tenantID string) (*sql.DB, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if elem, ok := p.conns[tenantID]; ok {
|
||||||
|
p.lru.MoveToFront(elem)
|
||||||
|
return elem.Value.(*conn).db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
useInMemory := p.inMemory[tenantID]
|
||||||
|
db, err := openDB(p.dataDir, tenantID, useInMemory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for p.lru.Len() >= maxOpenConns {
|
||||||
|
p.evictOldest()
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &conn{db: db, tenantID: tenantID}
|
||||||
|
elem := p.lru.PushFront(c)
|
||||||
|
p.conns[tenantID] = elem
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) evictOldest() {
|
||||||
|
elem := p.lru.Back()
|
||||||
|
if elem == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := elem.Value.(*conn)
|
||||||
|
c.db.Close()
|
||||||
|
delete(p.conns, c.tenantID)
|
||||||
|
delete(p.inMemory, c.tenantID)
|
||||||
|
p.lru.Remove(elem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) Evict(tenantID string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if elem, ok := p.conns[tenantID]; ok {
|
||||||
|
c := elem.Value.(*conn)
|
||||||
|
c.db.Close()
|
||||||
|
delete(p.conns, tenantID)
|
||||||
|
p.lru.Remove(elem)
|
||||||
|
}
|
||||||
|
delete(p.inMemory, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) Close() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
for p.lru.Len() > 0 {
|
||||||
|
p.evictOldest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
tenantID string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache stores subdomain to tenant ID mappings.
|
||||||
|
type Cache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]cacheEntry
|
||||||
|
stop chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCache() *Cache {
|
||||||
|
c := &Cache{
|
||||||
|
items: make(map[string]cacheEntry),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go c.cleanup()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Get(subdomain string) (string, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
entry, ok := c.items[subdomain]
|
||||||
|
if !ok || time.Now().After(entry.expiresAt) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return entry.tenantID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Set(subdomain, tenantID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.items[subdomain] = cacheEntry{
|
||||||
|
tenantID: tenantID,
|
||||||
|
expiresAt: time.Now().Add(cacheTTL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Delete(subdomain string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
delete(c.items, subdomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) cleanup() {
|
||||||
|
ticker := time.NewTicker(cacheCleanupFreq)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
c.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for k, v := range c.items {
|
||||||
|
if now.After(v.expiresAt) {
|
||||||
|
delete(c.items, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Close() {
|
||||||
|
close(c.stop)
|
||||||
|
}
|
||||||
481
internal/tenant/posts.go
Normal file
|
|
@ -0,0 +1,481 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queries) ListPosts(ctx context.Context, includeUnpublished bool) ([]Post, error) {
|
||||||
|
query := `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
|
||||||
|
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
|
||||||
|
FROM posts ORDER BY COALESCE(published_at, created_at) DESC`
|
||||||
|
if !includeUnpublished {
|
||||||
|
query = `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
|
||||||
|
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
|
||||||
|
FROM posts WHERE is_published = 1 ORDER BY COALESCE(published_at, created_at) DESC`
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.db.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var posts []Post
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanPost(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
posts = append(posts, p)
|
||||||
|
}
|
||||||
|
return posts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListPostsOptions struct {
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListPostsResult struct {
|
||||||
|
Posts []Post
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListPostsPaginated(ctx context.Context, opts ListPostsOptions) (*ListPostsResult, error) {
|
||||||
|
if opts.Limit <= 0 {
|
||||||
|
opts.Limit = 20
|
||||||
|
}
|
||||||
|
if opts.Limit > 100 {
|
||||||
|
opts.Limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
var args []any
|
||||||
|
where := "WHERE is_published = 1"
|
||||||
|
|
||||||
|
if opts.Tag != "" {
|
||||||
|
where += " AND tags LIKE ?"
|
||||||
|
args = append(args, "%\""+opts.Tag+"\"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int
|
||||||
|
countQuery := "SELECT COUNT(*) FROM posts " + where
|
||||||
|
err := q.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
|
||||||
|
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
|
||||||
|
FROM posts ` + where + ` ORDER BY COALESCE(published_at, created_at) DESC LIMIT ? OFFSET ?`
|
||||||
|
args = append(args, opts.Limit, opts.Offset)
|
||||||
|
|
||||||
|
rows, err := q.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var posts []Post
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanPost(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
posts = append(posts, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ListPostsResult{Posts: posts, Total: total}, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetPost(ctx context.Context, slug string) (*Post, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
|
||||||
|
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
|
||||||
|
FROM posts WHERE slug = ?`, slug)
|
||||||
|
|
||||||
|
p, err := scanPost(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetPostByID(ctx context.Context, id string) (*Post, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
|
||||||
|
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
|
||||||
|
FROM posts WHERE id = ?`, id)
|
||||||
|
|
||||||
|
p, err := scanPost(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetPostByAlias(ctx context.Context, alias string) (*Post, error) {
|
||||||
|
pattern := "%\"" + alias + "\"%"
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT id, slug, title, description, tags, cover_image, content_md, content_html,
|
||||||
|
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at
|
||||||
|
FROM posts WHERE aliases LIKE ? LIMIT 1`, pattern)
|
||||||
|
|
||||||
|
p, err := scanPost(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreatePost(ctx context.Context, p *Post) error {
|
||||||
|
if p.ID == "" {
|
||||||
|
p.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO posts (id, slug, title, description, tags, cover_image, content_md, content_html,
|
||||||
|
is_published, members_only, published_at, updated_at, aliases, created_at, modified_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
p.ID, p.Slug, nullStr(p.Title), nullStr(p.Description), jsonStr(p.Tags), nullStr(p.CoverImage),
|
||||||
|
nullStr(p.ContentMD), nullStr(p.ContentHTML), boolToInt(p.IsPublished), boolToInt(p.MembersOnly),
|
||||||
|
timeToStr(p.PublishedAt), timeToStr(p.UpdatedAt), jsonStr(p.Aliases), now, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
q.IndexPost(ctx, p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdatePost(ctx context.Context, p *Post) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
_, err := q.db.ExecContext(ctx, `UPDATE posts SET
|
||||||
|
slug = ?, title = ?, description = ?, tags = ?, cover_image = ?, content_md = ?, content_html = ?,
|
||||||
|
is_published = ?, members_only = ?, published_at = ?, updated_at = ?, aliases = ?, modified_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
p.Slug, nullStr(p.Title), nullStr(p.Description), jsonStr(p.Tags), nullStr(p.CoverImage),
|
||||||
|
nullStr(p.ContentMD), nullStr(p.ContentHTML), boolToInt(p.IsPublished), boolToInt(p.MembersOnly),
|
||||||
|
timeToStr(p.PublishedAt), timeToStr(p.UpdatedAt), jsonStr(p.Aliases), now, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
q.IndexPost(ctx, p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeletePost(ctx context.Context, id string) error {
|
||||||
|
post, _ := q.GetPostByID(ctx, id)
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM posts WHERE id = ?`, id)
|
||||||
|
if err == nil && post != nil {
|
||||||
|
q.RemoveFromIndex(ctx, post.Slug, "post")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetDraft(ctx context.Context, postID string) (*PostDraft, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT post_id, slug, title, description, tags, cover_image, members_only, content_md, content_html, modified_at
|
||||||
|
FROM post_drafts WHERE post_id = ?`, postID)
|
||||||
|
|
||||||
|
d, err := scanDraft(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SaveDraft(ctx context.Context, d *PostDraft) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO post_drafts (post_id, slug, title, description, tags, cover_image, members_only, content_md, content_html, modified_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(post_id) DO UPDATE SET
|
||||||
|
slug = excluded.slug, title = excluded.title, description = excluded.description, tags = excluded.tags,
|
||||||
|
cover_image = excluded.cover_image, members_only = excluded.members_only, content_md = excluded.content_md,
|
||||||
|
content_html = excluded.content_html, modified_at = excluded.modified_at`,
|
||||||
|
d.PostID, d.Slug, nullStr(d.Title), nullStr(d.Description), jsonStr(d.Tags), nullStr(d.CoverImage),
|
||||||
|
boolToInt(d.MembersOnly), nullStr(d.ContentMD), nullStr(d.ContentHTML), now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteDraft(ctx context.Context, postID string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM post_drafts WHERE post_id = ?`, postID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) HasDraft(ctx context.Context, postID string) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM post_drafts WHERE post_id = ?`, postID).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateVersion(ctx context.Context, p *Post) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO post_versions (post_id, slug, title, description, tags, cover_image, content_md, content_html, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
p.ID, p.Slug, nullStr(p.Title), nullStr(p.Description), jsonStr(p.Tags), nullStr(p.CoverImage),
|
||||||
|
nullStr(p.ContentMD), nullStr(p.ContentHTML), now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListVersions(ctx context.Context, postID string) ([]PostVersion, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT id, post_id, slug, title, description, tags, cover_image, content_md, content_html, created_at
|
||||||
|
FROM post_versions WHERE post_id = ? ORDER BY created_at DESC`, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var versions []PostVersion
|
||||||
|
for rows.Next() {
|
||||||
|
v, err := scanVersion(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
return versions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetVersion(ctx context.Context, versionID int64) (*PostVersion, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT id, post_id, slug, title, description, tags, cover_image, content_md, content_html, created_at
|
||||||
|
FROM post_versions WHERE id = ?`, versionID)
|
||||||
|
|
||||||
|
v, err := scanVersion(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) PruneVersions(ctx context.Context, postID string, keepCount int) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM post_versions AS pv
|
||||||
|
WHERE pv.post_id = ?1 AND pv.id NOT IN (
|
||||||
|
SELECT sub.id FROM post_versions AS sub WHERE sub.post_id = ?1 ORDER BY sub.created_at DESC LIMIT ?2
|
||||||
|
)`, postID, keepCount)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) Publish(ctx context.Context, postID string) error {
|
||||||
|
post, err := q.GetPostByID(ctx, postID)
|
||||||
|
if err != nil || post == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
draft, err := q.GetDraft(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
if draft != nil {
|
||||||
|
if draft.Slug != post.Slug {
|
||||||
|
if !contains(post.Aliases, post.Slug) {
|
||||||
|
post.Aliases = append(post.Aliases, post.Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post.Slug = draft.Slug
|
||||||
|
post.Title = draft.Title
|
||||||
|
post.Description = draft.Description
|
||||||
|
post.Tags = draft.Tags
|
||||||
|
post.CoverImage = draft.CoverImage
|
||||||
|
post.MembersOnly = draft.MembersOnly
|
||||||
|
post.ContentMD = draft.ContentMD
|
||||||
|
post.ContentHTML = draft.ContentHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
if post.PublishedAt == nil {
|
||||||
|
post.PublishedAt = &now
|
||||||
|
}
|
||||||
|
post.UpdatedAt = &now
|
||||||
|
post.IsPublished = true
|
||||||
|
|
||||||
|
if err := q.UpdatePost(ctx, post); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := q.CreateVersion(ctx, post); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := q.DeleteDraft(ctx, postID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return q.PruneVersions(ctx, postID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) Unpublish(ctx context.Context, postID string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := q.db.ExecContext(ctx, `UPDATE posts SET is_published = 0, modified_at = ? WHERE id = ?`, now, postID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RestoreVersion(ctx context.Context, postID string, versionID int64) error {
|
||||||
|
version, err := q.GetVersion(ctx, versionID)
|
||||||
|
if err != nil || version == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.SaveDraft(ctx, &PostDraft{
|
||||||
|
PostID: postID,
|
||||||
|
Slug: version.Slug,
|
||||||
|
Title: version.Title,
|
||||||
|
Description: version.Description,
|
||||||
|
Tags: version.Tags,
|
||||||
|
CoverImage: version.CoverImage,
|
||||||
|
ContentMD: version.ContentMD,
|
||||||
|
ContentHTML: version.ContentHTML,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPost(s scanner) (Post, error) {
|
||||||
|
var p Post
|
||||||
|
var title, desc, tags, cover, md, html, pubAt, updAt, aliases, createdAt, modAt sql.NullString
|
||||||
|
var isPub, memOnly sql.NullInt64
|
||||||
|
|
||||||
|
err := s.Scan(&p.ID, &p.Slug, &title, &desc, &tags, &cover, &md, &html,
|
||||||
|
&isPub, &memOnly, &pubAt, &updAt, &aliases, &createdAt, &modAt)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Title = title.String
|
||||||
|
p.Description = desc.String
|
||||||
|
p.Tags = parseJSON[[]string](tags.String)
|
||||||
|
p.CoverImage = cover.String
|
||||||
|
p.ContentMD = md.String
|
||||||
|
p.ContentHTML = html.String
|
||||||
|
p.IsPublished = isPub.Int64 == 1
|
||||||
|
p.MembersOnly = memOnly.Int64 == 1
|
||||||
|
p.PublishedAt = parseTimePtr(pubAt.String)
|
||||||
|
p.UpdatedAt = parseTimePtr(updAt.String)
|
||||||
|
p.Aliases = parseJSON[[]string](aliases.String)
|
||||||
|
p.CreatedAt = parseTime(createdAt.String)
|
||||||
|
p.ModifiedAt = parseTime(modAt.String)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDraft(s scanner) (PostDraft, error) {
|
||||||
|
var d PostDraft
|
||||||
|
var title, desc, tags, cover, md, html, modAt sql.NullString
|
||||||
|
var memOnly sql.NullInt64
|
||||||
|
|
||||||
|
err := s.Scan(&d.PostID, &d.Slug, &title, &desc, &tags, &cover, &memOnly, &md, &html, &modAt)
|
||||||
|
if err != nil {
|
||||||
|
return d, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Title = title.String
|
||||||
|
d.Description = desc.String
|
||||||
|
d.Tags = parseJSON[[]string](tags.String)
|
||||||
|
d.CoverImage = cover.String
|
||||||
|
d.MembersOnly = memOnly.Int64 == 1
|
||||||
|
d.ContentMD = md.String
|
||||||
|
d.ContentHTML = html.String
|
||||||
|
d.ModifiedAt = parseTime(modAt.String)
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanVersion(s scanner) (PostVersion, error) {
|
||||||
|
var v PostVersion
|
||||||
|
var title, desc, tags, cover, md, html, createdAt sql.NullString
|
||||||
|
|
||||||
|
err := s.Scan(&v.ID, &v.PostID, &v.Slug, &title, &desc, &tags, &cover, &md, &html, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
v.Title = title.String
|
||||||
|
v.Description = desc.String
|
||||||
|
v.Tags = parseJSON[[]string](tags.String)
|
||||||
|
v.CoverImage = cover.String
|
||||||
|
v.ContentMD = md.String
|
||||||
|
v.ContentHTML = html.String
|
||||||
|
v.CreatedAt = parseTime(createdAt.String)
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullStr(s string) sql.NullString {
|
||||||
|
return sql.NullString{String: s, Valid: s != ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonStr[T any](v T) sql.NullString {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
s := string(b)
|
||||||
|
if s == "null" || s == "[]" {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
return sql.NullString{String: s, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJSON[T any](s string) T {
|
||||||
|
var v T
|
||||||
|
if s != "" {
|
||||||
|
json.Unmarshal([]byte(s), &v)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(s string) time.Time {
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
for _, layout := range []string{time.RFC3339, "2006-01-02", "2006-01-02 15:04:05"} {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTimePtr(s string) *time.Time {
|
||||||
|
t := parseTime(s)
|
||||||
|
if t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeToStr(t *time.Time) sql.NullString {
|
||||||
|
if t == nil {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
return sql.NullString{String: t.UTC().Format(time.RFC3339), Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(slice []string, item string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if s == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
24
internal/tenant/queries.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB interface {
|
||||||
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||||
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
db DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQueries(db *sql.DB) *Queries {
|
||||||
|
return &Queries{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||||
|
return &Queries{db: tx}
|
||||||
|
}
|
||||||
159
internal/tenant/reactions.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queries) ListReactions(ctx context.Context, postSlug string) ([]Reaction, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT id, user_id, anon_id, post_slug, emoji, created_at
|
||||||
|
FROM reactions WHERE post_slug = ?`, postSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var reactions []Reaction
|
||||||
|
for rows.Next() {
|
||||||
|
r, err := scanReaction(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reactions = append(reactions, r)
|
||||||
|
}
|
||||||
|
return reactions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetReactionCounts(ctx context.Context, postSlug string) (map[string]int, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT emoji, COUNT(*) as count FROM reactions WHERE post_slug = ? GROUP BY emoji`, postSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
counts := make(map[string]int)
|
||||||
|
for rows.Next() {
|
||||||
|
var emoji string
|
||||||
|
var count int64
|
||||||
|
if err := rows.Scan(&emoji, &count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts[emoji] = int(count)
|
||||||
|
}
|
||||||
|
return counts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ToggleReaction(ctx context.Context, userID, anonID, postSlug, emoji string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
|
||||||
|
if userID != "" {
|
||||||
|
var dummy int64
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT 1 FROM reactions WHERE user_id = ? AND post_slug = ? AND emoji = ?`,
|
||||||
|
userID, postSlug, emoji).Scan(&dummy)
|
||||||
|
exists = err == nil
|
||||||
|
} else if anonID != "" {
|
||||||
|
var dummy int64
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT 1 FROM reactions WHERE anon_id = ? AND post_slug = ? AND emoji = ?`,
|
||||||
|
anonID, postSlug, emoji).Scan(&dummy)
|
||||||
|
exists = err == nil
|
||||||
|
} else {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
if userID != "" {
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO reactions (user_id, post_slug, emoji) VALUES (?, ?, ?)`,
|
||||||
|
userID, postSlug, emoji)
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO reactions (anon_id, post_slug, emoji) VALUES (?, ?, ?)`,
|
||||||
|
anonID, postSlug, emoji)
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID != "" {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM reactions WHERE user_id = ? AND post_slug = ? AND emoji = ?`,
|
||||||
|
userID, postSlug, emoji)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM reactions WHERE anon_id = ? AND post_slug = ? AND emoji = ?`,
|
||||||
|
anonID, postSlug, emoji)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUserReactions(ctx context.Context, userID, postSlug string) ([]string, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT emoji FROM reactions WHERE user_id = ? AND post_slug = ?`, userID, postSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var emojis []string
|
||||||
|
for rows.Next() {
|
||||||
|
var emoji string
|
||||||
|
if err := rows.Scan(&emoji); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
emojis = append(emojis, emoji)
|
||||||
|
}
|
||||||
|
return emojis, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAnonReactions(ctx context.Context, anonID, postSlug string) ([]string, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT emoji FROM reactions WHERE anon_id = ? AND post_slug = ?`, anonID, postSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var emojis []string
|
||||||
|
for rows.Next() {
|
||||||
|
var emoji string
|
||||||
|
if err := rows.Scan(&emoji); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
emojis = append(emojis, emoji)
|
||||||
|
}
|
||||||
|
return emojis, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) HasUserReacted(ctx context.Context, userID, postSlug string) (bool, error) {
|
||||||
|
var dummy int64
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT 1 FROM reactions WHERE user_id = ? AND post_slug = ? LIMIT 1`,
|
||||||
|
userID, postSlug).Scan(&dummy)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) HasAnonReacted(ctx context.Context, anonID, postSlug string) (bool, error) {
|
||||||
|
var dummy int64
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT 1 FROM reactions WHERE anon_id = ? AND post_slug = ? LIMIT 1`,
|
||||||
|
anonID, postSlug).Scan(&dummy)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanReaction(s scanner) (Reaction, error) {
|
||||||
|
var r Reaction
|
||||||
|
var userID, anonID, createdAt sql.NullString
|
||||||
|
|
||||||
|
err := s.Scan(&r.ID, &userID, &anonID, &r.PostSlug, &r.Emoji, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.UserID = userID.String
|
||||||
|
r.AnonID = anonID.String
|
||||||
|
r.CreatedAt = parseTime(createdAt.String)
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
621
internal/tenant/runner.go
Normal file
|
|
@ -0,0 +1,621 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
extism "github.com/extism/go-sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PluginRunner struct {
|
||||||
|
db *sql.DB
|
||||||
|
q *Queries
|
||||||
|
tenantID string
|
||||||
|
cache map[string]*extism.Plugin
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPluginRunner(db *sql.DB, tenantID string) *PluginRunner {
|
||||||
|
return &PluginRunner{
|
||||||
|
db: db,
|
||||||
|
q: NewQueries(db),
|
||||||
|
tenantID: tenantID,
|
||||||
|
cache: make(map[string]*extism.Plugin),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HookEvent struct {
|
||||||
|
Hook string `json:"hook"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginResult struct {
|
||||||
|
PluginID string `json:"plugin_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Duration int64 `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerHook executes plugins for an event hook (fire-and-forget)
|
||||||
|
func (r *PluginRunner) TriggerHook(ctx context.Context, hook string, data map[string]any) []PluginResult {
|
||||||
|
plugins, err := r.q.GetPluginsByHook(ctx, hook)
|
||||||
|
if err != nil || len(plugins) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
secrets, _ := GetSecretsMap(r.db, r.tenantID)
|
||||||
|
|
||||||
|
var results []PluginResult
|
||||||
|
for _, p := range plugins {
|
||||||
|
result := r.runPlugin(ctx, &p, hook, data, secrets)
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationResult represents the result of a validation hook
|
||||||
|
type ValidationResult struct {
|
||||||
|
Allowed bool `json:"allowed"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerValidation executes a validation hook and returns whether the action is allowed
|
||||||
|
// Returns (allowed, reason, error). If no plugins exist, allowed defaults to true.
|
||||||
|
func (r *PluginRunner) TriggerValidation(ctx context.Context, hook string, data map[string]any) (bool, string, error) {
|
||||||
|
plugins, err := r.q.GetPluginsByHook(ctx, hook)
|
||||||
|
if err != nil {
|
||||||
|
return true, "", err // Default to allowed on error
|
||||||
|
}
|
||||||
|
if len(plugins) == 0 {
|
||||||
|
return true, "", nil // Default to allowed if no plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
secrets, _ := GetSecretsMap(r.db, r.tenantID)
|
||||||
|
|
||||||
|
// Run first enabled plugin only (validation is exclusive)
|
||||||
|
for _, p := range plugins {
|
||||||
|
result := r.runPlugin(ctx, &p, hook, data, secrets)
|
||||||
|
if !result.Success {
|
||||||
|
// Plugin failed to run, default to allowed
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var validation ValidationResult
|
||||||
|
if err := json.Unmarshal([]byte(result.Output), &validation); err != nil {
|
||||||
|
continue // Invalid output, skip this plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validation.Allowed {
|
||||||
|
return false, validation.Reason, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerTransform executes a transform hook and returns the transformed data
|
||||||
|
// If no plugins exist or all fail, returns the original data unchanged.
|
||||||
|
func (r *PluginRunner) TriggerTransform(ctx context.Context, hook string, data map[string]any) (map[string]any, error) {
|
||||||
|
plugins, err := r.q.GetPluginsByHook(ctx, hook)
|
||||||
|
if err != nil || len(plugins) == 0 {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
secrets, _ := GetSecretsMap(r.db, r.tenantID)
|
||||||
|
current := data
|
||||||
|
|
||||||
|
// Chain transforms - each plugin receives output of previous
|
||||||
|
for _, p := range plugins {
|
||||||
|
result := r.runPlugin(ctx, &p, hook, current, secrets)
|
||||||
|
if !result.Success {
|
||||||
|
continue // Skip failed plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
var transformed map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(result.Output), &transformed); err != nil {
|
||||||
|
continue // Invalid output, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
current = transformed
|
||||||
|
}
|
||||||
|
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginRunner) runPlugin(ctx context.Context, p *Plugin, hook string, data map[string]any, secrets map[string]string) PluginResult {
|
||||||
|
start := time.Now()
|
||||||
|
result := PluginResult{PluginID: p.ID}
|
||||||
|
|
||||||
|
plugin, err := r.getOrCreatePlugin(p, secrets)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
result.Duration = time.Since(start).Milliseconds()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
input, _ := json.Marshal(data)
|
||||||
|
|
||||||
|
funcName := hookToFunction(hook)
|
||||||
|
_, output, err := plugin.Call(funcName, input)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
result.Success = true
|
||||||
|
result.Output = string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Duration = time.Since(start).Milliseconds()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginRunner) getOrCreatePlugin(p *Plugin, secrets map[string]string) (*extism.Plugin, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
cached, ok := r.cache[p.ID]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if cached, ok = r.cache[p.ID]; ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := extism.Manifest{
|
||||||
|
Wasm: []extism.Wasm{
|
||||||
|
extism.WasmData{Data: p.Wasm},
|
||||||
|
},
|
||||||
|
AllowedHosts: []string{"*"},
|
||||||
|
Config: secrets,
|
||||||
|
}
|
||||||
|
|
||||||
|
config := extism.PluginConfig{
|
||||||
|
EnableWasi: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin, err := extism.NewPlugin(context.Background(), manifest, config, r.hostFunctions())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create plugin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.cache[p.ID] = plugin
|
||||||
|
return plugin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginRunner) hostFunctions() []extism.HostFunction {
|
||||||
|
return []extism.HostFunction{
|
||||||
|
r.httpRequestHost(),
|
||||||
|
r.kvGetHost(),
|
||||||
|
r.kvSetHost(),
|
||||||
|
r.logHost(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginRunner) httpRequestHost() extism.HostFunction {
|
||||||
|
return extism.NewHostFunctionWithStack(
|
||||||
|
"http_request",
|
||||||
|
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||||
|
input, err := p.ReadBytes(stack[0])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(input, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Method == "" {
|
||||||
|
req.Method = "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, bytes.NewBufferString(req.Body))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range req.Headers {
|
||||||
|
httpReq.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
errResp, _ := json.Marshal(map[string]any{"error": err.Error()})
|
||||||
|
offset, _ := p.WriteBytes(errResp)
|
||||||
|
stack[0] = offset
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
headers := make(map[string]string)
|
||||||
|
for k := range resp.Header {
|
||||||
|
headers[k] = resp.Header.Get(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ := json.Marshal(map[string]any{
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"headers": headers,
|
||||||
|
"body": string(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
offset, _ := p.WriteBytes(result)
|
||||||
|
stack[0] = offset
|
||||||
|
},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginRunner) kvGetHost() extism.HostFunction {
|
||||||
|
return extism.NewHostFunctionWithStack(
|
||||||
|
"kv_get",
|
||||||
|
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||||
|
key, err := p.ReadString(stack[0])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
value, _ := GetSecret(r.db, r.tenantID, "kv:"+key)
|
||||||
|
offset, _ := p.WriteString(value)
|
||||||
|
stack[0] = offset
|
||||||
|
},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginRunner) kvSetHost() extism.HostFunction {
|
||||||
|
return extism.NewHostFunctionWithStack(
|
||||||
|
"kv_set",
|
||||||
|
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||||
|
input, err := p.ReadBytes(stack[0])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var kv struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(input, &kv); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SetSecret(r.db, r.tenantID, "kv:"+kv.Key, kv.Value)
|
||||||
|
stack[0] = 0
|
||||||
|
},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginRunner) logHost() extism.HostFunction {
|
||||||
|
return extism.NewHostFunctionWithStack(
|
||||||
|
"log",
|
||||||
|
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||||
|
msg, _ := p.ReadString(stack[0])
|
||||||
|
fmt.Printf("[plugin] %s\n", msg)
|
||||||
|
stack[0] = 0
|
||||||
|
},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginRunner) InvalidateCache(pluginID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if plugin, ok := r.cache[pluginID]; ok {
|
||||||
|
plugin.Close(context.Background())
|
||||||
|
delete(r.cache, pluginID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginRunner) Close() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
for _, plugin := range r.cache {
|
||||||
|
plugin.Close(context.Background())
|
||||||
|
}
|
||||||
|
r.cache = make(map[string]*extism.Plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookPattern defines how a hook should be executed
|
||||||
|
type HookPattern string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PatternEvent HookPattern = "event" // Fire-and-forget notifications
|
||||||
|
PatternValidation HookPattern = "validation" // Returns allowed/rejected decision
|
||||||
|
PatternTransform HookPattern = "transform" // Modifies and returns data
|
||||||
|
)
|
||||||
|
|
||||||
|
// HookInfo contains metadata about a hook
|
||||||
|
type HookInfo struct {
|
||||||
|
Name string
|
||||||
|
Pattern HookPattern
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailableHooks lists all supported hooks with metadata
|
||||||
|
var AvailableHooks = []HookInfo{
|
||||||
|
// Content hooks
|
||||||
|
{Name: "post.published", Pattern: PatternEvent, Description: "Triggered when a post is published"},
|
||||||
|
{Name: "post.updated", Pattern: PatternEvent, Description: "Triggered when a post is updated"},
|
||||||
|
{Name: "content.render", Pattern: PatternTransform, Description: "Transform HTML before display"},
|
||||||
|
|
||||||
|
// Engagement hooks
|
||||||
|
{Name: "comment.validate", Pattern: PatternValidation, Description: "Validate comment before creation"},
|
||||||
|
{Name: "comment.created", Pattern: PatternEvent, Description: "Triggered when a comment is created"},
|
||||||
|
{Name: "member.subscribed", Pattern: PatternEvent, Description: "Triggered when a member subscribes"},
|
||||||
|
|
||||||
|
// Utility hooks
|
||||||
|
{Name: "asset.uploaded", Pattern: PatternEvent, Description: "Triggered when an asset is uploaded"},
|
||||||
|
{Name: "analytics.sync", Pattern: PatternEvent, Description: "Triggered during analytics sync"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHookPattern returns the pattern for a given hook
|
||||||
|
func GetHookPattern(hook string) HookPattern {
|
||||||
|
for _, h := range AvailableHooks {
|
||||||
|
if h.Name == hook {
|
||||||
|
return h.Pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PatternEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHookNames returns just the hook names (for API responses)
|
||||||
|
func GetHookNames() []string {
|
||||||
|
names := make([]string, len(AvailableHooks))
|
||||||
|
for i, h := range AvailableHooks {
|
||||||
|
names[i] = h.Name
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPluginRunner runs plugins for testing with log capture
|
||||||
|
type TestPluginRunner struct {
|
||||||
|
db *sql.DB
|
||||||
|
tenantID string
|
||||||
|
secrets map[string]string
|
||||||
|
logs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResult contains the result of a plugin test run
|
||||||
|
type TestResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Logs []string `json:"logs"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Duration int64 `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestPluginRunner(db *sql.DB, tenantID string, secrets map[string]string) *TestPluginRunner {
|
||||||
|
return &TestPluginRunner{
|
||||||
|
db: db,
|
||||||
|
tenantID: tenantID,
|
||||||
|
secrets: secrets,
|
||||||
|
logs: []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestPluginRunner) RunTest(ctx context.Context, wasm []byte, hook string, data map[string]any) TestResult {
|
||||||
|
start := time.Now()
|
||||||
|
result := TestResult{Logs: []string{}}
|
||||||
|
|
||||||
|
manifest := extism.Manifest{
|
||||||
|
Wasm: []extism.Wasm{
|
||||||
|
extism.WasmData{Data: wasm},
|
||||||
|
},
|
||||||
|
AllowedHosts: []string{"*"},
|
||||||
|
Config: r.secrets,
|
||||||
|
}
|
||||||
|
|
||||||
|
config := extism.PluginConfig{
|
||||||
|
EnableWasi: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin, err := extism.NewPlugin(ctx, manifest, config, r.testHostFunctions())
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("Failed to create plugin: %v", err)
|
||||||
|
result.Duration = time.Since(start).Milliseconds()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer plugin.Close(ctx)
|
||||||
|
|
||||||
|
input, _ := json.Marshal(data)
|
||||||
|
funcName := hookToFunction(hook)
|
||||||
|
|
||||||
|
_, output, err := plugin.Call(funcName, input)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
result.Success = true
|
||||||
|
result.Output = string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Logs = r.logs
|
||||||
|
result.Duration = time.Since(start).Milliseconds()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestPluginRunner) testHostFunctions() []extism.HostFunction {
|
||||||
|
return []extism.HostFunction{
|
||||||
|
r.testHttpRequestHost(),
|
||||||
|
r.testKvGetHost(),
|
||||||
|
r.testKvSetHost(),
|
||||||
|
r.testLogHost(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestPluginRunner) testHttpRequestHost() extism.HostFunction {
|
||||||
|
return extism.NewHostFunctionWithStack(
|
||||||
|
"http_request",
|
||||||
|
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||||
|
input, err := p.ReadBytes(stack[0])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(input, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Method == "" {
|
||||||
|
req.Method = "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logs = append(r.logs, fmt.Sprintf("[HTTP] %s %s", req.Method, req.URL))
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, bytes.NewBufferString(req.Body))
|
||||||
|
if err != nil {
|
||||||
|
errResp, _ := json.Marshal(map[string]any{"error": err.Error()})
|
||||||
|
offset, _ := p.WriteBytes(errResp)
|
||||||
|
stack[0] = offset
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range req.Headers {
|
||||||
|
httpReq.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
r.logs = append(r.logs, fmt.Sprintf("[HTTP] Error: %v", err))
|
||||||
|
errResp, _ := json.Marshal(map[string]any{"error": err.Error()})
|
||||||
|
offset, _ := p.WriteBytes(errResp)
|
||||||
|
stack[0] = offset
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
r.logs = append(r.logs, fmt.Sprintf("[HTTP] Response: %d (%d bytes)", resp.StatusCode, len(body)))
|
||||||
|
|
||||||
|
headers := make(map[string]string)
|
||||||
|
for k := range resp.Header {
|
||||||
|
headers[k] = resp.Header.Get(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _ := json.Marshal(map[string]any{
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"headers": headers,
|
||||||
|
"body": string(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
offset, _ := p.WriteBytes(result)
|
||||||
|
stack[0] = offset
|
||||||
|
},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestPluginRunner) testKvGetHost() extism.HostFunction {
|
||||||
|
return extism.NewHostFunctionWithStack(
|
||||||
|
"kv_get",
|
||||||
|
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||||
|
key, err := p.ReadString(stack[0])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
value, _ := GetSecret(r.db, r.tenantID, "kv:"+key)
|
||||||
|
r.logs = append(r.logs, fmt.Sprintf("[KV] GET %s", key))
|
||||||
|
offset, _ := p.WriteString(value)
|
||||||
|
stack[0] = offset
|
||||||
|
},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestPluginRunner) testKvSetHost() extism.HostFunction {
|
||||||
|
return extism.NewHostFunctionWithStack(
|
||||||
|
"kv_set",
|
||||||
|
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||||
|
input, err := p.ReadBytes(stack[0])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var kv struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(input, &kv); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logs = append(r.logs, fmt.Sprintf("[KV] SET %s = %s", kv.Key, kv.Value))
|
||||||
|
SetSecret(r.db, r.tenantID, "kv:"+kv.Key, kv.Value)
|
||||||
|
stack[0] = 0
|
||||||
|
},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestPluginRunner) testLogHost() extism.HostFunction {
|
||||||
|
return extism.NewHostFunctionWithStack(
|
||||||
|
"log",
|
||||||
|
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||||
|
msg, _ := p.ReadString(stack[0])
|
||||||
|
r.logs = append(r.logs, fmt.Sprintf("[LOG] %s", msg))
|
||||||
|
stack[0] = 0
|
||||||
|
},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
[]extism.ValueType{extism.ValueTypeI64},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hookToFunction(hook string) string {
|
||||||
|
switch hook {
|
||||||
|
case "post.published":
|
||||||
|
return "on_post_published"
|
||||||
|
case "post.updated":
|
||||||
|
return "on_post_updated"
|
||||||
|
case "content.render":
|
||||||
|
return "render_content"
|
||||||
|
case "comment.validate":
|
||||||
|
return "validate_comment"
|
||||||
|
case "comment.created":
|
||||||
|
return "on_comment_created"
|
||||||
|
case "member.subscribed":
|
||||||
|
return "on_member_subscribed"
|
||||||
|
case "asset.uploaded":
|
||||||
|
return "on_asset_uploaded"
|
||||||
|
case "analytics.sync":
|
||||||
|
return "on_analytics_sync"
|
||||||
|
default:
|
||||||
|
return "run"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/tenant/search.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
Slug string
|
||||||
|
Collection string
|
||||||
|
Title string
|
||||||
|
Snippet string
|
||||||
|
Type string
|
||||||
|
URL string
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.db.QueryContext(ctx,
|
||||||
|
`SELECT slug, collection_slug, title, snippet(search_index, 4, '<mark>', '</mark>', '...', 32), type, url, date
|
||||||
|
FROM search_index
|
||||||
|
WHERE search_index MATCH ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ?`, query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []SearchResult
|
||||||
|
for rows.Next() {
|
||||||
|
var r SearchResult
|
||||||
|
if err := rows.Scan(&r.Slug, &r.Collection, &r.Title, &r.Snippet, &r.Type, &r.URL, &r.Date); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) IndexPost(ctx context.Context, p *Post) error {
|
||||||
|
q.db.ExecContext(ctx, `DELETE FROM search_index WHERE slug = ? AND type = 'post'`, p.Slug)
|
||||||
|
|
||||||
|
if !p.IsPublished {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dateStr := ""
|
||||||
|
if p.PublishedAt != nil {
|
||||||
|
dateStr = p.PublishedAt.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := q.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO search_index (slug, collection_slug, title, description, content, type, url, date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'post', ?, ?)`,
|
||||||
|
p.Slug, "", p.Title, p.Description, p.ContentMD,
|
||||||
|
"/posts/"+p.Slug, dateStr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RemoveFromIndex(ctx context.Context, slug, itemType string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM search_index WHERE slug = ? AND type = ?`, slug, itemType)
|
||||||
|
return err
|
||||||
|
}
|
||||||
202
internal/tenant/secrets.go
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Secret struct {
|
||||||
|
Key string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var masterKey []byte
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
key := os.Getenv("SECRETS_MASTER_KEY")
|
||||||
|
if key == "" {
|
||||||
|
key = "writekit-dev-key-change-in-prod"
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256([]byte(key))
|
||||||
|
masterKey = hash[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveKey(tenantID string) []byte {
|
||||||
|
combined := append(masterKey, []byte(tenantID)...)
|
||||||
|
hash := sha256.Sum256(combined)
|
||||||
|
return hash[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func encrypt(plaintext []byte, tenantID string) (ciphertext, nonce []byte, err error) {
|
||||||
|
key := deriveKey(tenantID)
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce = make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext = gcm.Seal(nil, nonce, plaintext, nil)
|
||||||
|
return ciphertext, nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrypt(ciphertext, nonce []byte, tenantID string) ([]byte, error) {
|
||||||
|
key := deriveKey(tenantID)
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSecretsTable(db *sql.DB) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS secrets (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value BLOB NOT NULL,
|
||||||
|
nonce BLOB NOT NULL,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetSecret(db *sql.DB, tenantID, key, value string) error {
|
||||||
|
if err := ensureSecretsTable(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext, nonce, err := encrypt([]byte(value), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
INSERT INTO secrets (key, value, nonce, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
nonce = excluded.nonce,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, key, ciphertext, nonce)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSecret(db *sql.DB, tenantID, key string) (string, error) {
|
||||||
|
if err := ensureSecretsTable(db); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ciphertext, nonce []byte
|
||||||
|
err := db.QueryRow(`SELECT value, nonce FROM secrets WHERE key = ?`, key).Scan(&ciphertext, &nonce)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := decrypt(ciphertext, nonce, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteSecret(db *sql.DB, key string) error {
|
||||||
|
if err := ensureSecretsTable(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.Exec(`DELETE FROM secrets WHERE key = ?`, key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListSecrets(db *sql.DB) ([]Secret, error) {
|
||||||
|
if err := ensureSecretsTable(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(`SELECT key, created_at, updated_at FROM secrets ORDER BY key`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var secrets []Secret
|
||||||
|
for rows.Next() {
|
||||||
|
var s Secret
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
if err := rows.Scan(&s.Key, &createdAt, &updatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
s.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||||
|
secrets = append(secrets, s)
|
||||||
|
}
|
||||||
|
return secrets, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSecretsMap(db *sql.DB, tenantID string) (map[string]string, error) {
|
||||||
|
if err := ensureSecretsTable(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(`SELECT key, value, nonce FROM secrets`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
secrets := make(map[string]string)
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
var ciphertext, nonce []byte
|
||||||
|
if err := rows.Scan(&key, &ciphertext, &nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plaintext, err := decrypt(ciphertext, nonce, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
secrets[key] = string(plaintext)
|
||||||
|
}
|
||||||
|
return secrets, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func MaskSecret(value string) string {
|
||||||
|
if len(value) <= 8 {
|
||||||
|
return "••••••••"
|
||||||
|
}
|
||||||
|
return value[:4] + "••••" + value[len(value)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateSecretID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
84
internal/tenant/settings.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queries) GetSettings(ctx context.Context) (Settings, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `SELECT key, value FROM site_settings`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
settings := make(Settings)
|
||||||
|
for rows.Next() {
|
||||||
|
var key, value string
|
||||||
|
if err := rows.Scan(&key, &value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
settings[key] = value
|
||||||
|
}
|
||||||
|
return settings, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetSetting(ctx context.Context, key string) (string, error) {
|
||||||
|
var value string
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT value FROM site_settings WHERE key = ?`, key).Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetSetting(ctx context.Context, key, value string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO site_settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`, key, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetSettings(ctx context.Context, settings Settings) error {
|
||||||
|
for key, value := range settings {
|
||||||
|
if err := q.SetSetting(ctx, key, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSetting(ctx context.Context, key string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM site_settings WHERE key = ?`, key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultSettings = map[string]string{
|
||||||
|
"comments_enabled": "true",
|
||||||
|
"reactions_enabled": "true",
|
||||||
|
"reaction_mode": "upvote",
|
||||||
|
"reaction_emojis": "👍,❤️,😂,😮,😢",
|
||||||
|
"upvote_icon": "👍",
|
||||||
|
"reactions_require_auth": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetSettingWithDefault(ctx context.Context, key string) string {
|
||||||
|
value, err := q.GetSetting(ctx, key)
|
||||||
|
if err != nil || value == "" {
|
||||||
|
if def, ok := defaultSettings[key]; ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetInteractionConfig(ctx context.Context) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"comments_enabled": q.GetSettingWithDefault(ctx, "comments_enabled") == "true",
|
||||||
|
"reactions_enabled": q.GetSettingWithDefault(ctx, "reactions_enabled") == "true",
|
||||||
|
"reaction_mode": q.GetSettingWithDefault(ctx, "reaction_mode"),
|
||||||
|
"reaction_emojis": q.GetSettingWithDefault(ctx, "reaction_emojis"),
|
||||||
|
"upvote_icon": q.GetSettingWithDefault(ctx, "upvote_icon"),
|
||||||
|
"reactions_require_auth": q.GetSettingWithDefault(ctx, "reactions_require_auth") == "true",
|
||||||
|
}
|
||||||
|
}
|
||||||
273
internal/tenant/sqlite.go
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openDB(dataDir, tenantID string, inMemory bool) (*sql.DB, error) {
|
||||||
|
var dsn string
|
||||||
|
if inMemory {
|
||||||
|
// named in-memory DB with shared cache so all 'connections' share the same database
|
||||||
|
dsn = "file:" + tenantID + "?mode=memory&cache=shared&_pragma=busy_timeout(5000)"
|
||||||
|
} else {
|
||||||
|
dbPath := filepath.Join(dataDir, tenantID+".db")
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dsn = dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := initSchema(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initSchema(db *sql.DB) error {
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
cover_image TEXT,
|
||||||
|
content_md TEXT,
|
||||||
|
content_html TEXT,
|
||||||
|
is_published INTEGER DEFAULT 0,
|
||||||
|
members_only INTEGER DEFAULT 0,
|
||||||
|
published_at TEXT,
|
||||||
|
updated_at TEXT,
|
||||||
|
aliases TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
modified_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published_at DESC) WHERE is_published = 1;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_drafts (
|
||||||
|
post_id TEXT PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
cover_image TEXT,
|
||||||
|
members_only INTEGER DEFAULT 0,
|
||||||
|
content_md TEXT,
|
||||||
|
content_html TEXT,
|
||||||
|
modified_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_versions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
cover_image TEXT,
|
||||||
|
content_md TEXT,
|
||||||
|
content_html TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_versions_post ON post_versions(post_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
html BLOB,
|
||||||
|
etag TEXT,
|
||||||
|
built_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS assets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
r2_key TEXT NOT NULL,
|
||||||
|
content_type TEXT,
|
||||||
|
size INTEGER,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
post_slug TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
content_html TEXT,
|
||||||
|
parent_id INTEGER REFERENCES comments(id),
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
anon_id TEXT,
|
||||||
|
post_slug TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, post_slug, emoji),
|
||||||
|
UNIQUE(anon_id, post_slug, emoji)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reactions_post ON reactions(post_slug);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS page_views (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
post_slug TEXT,
|
||||||
|
referrer TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
visitor_hash TEXT,
|
||||||
|
utm_source TEXT,
|
||||||
|
utm_medium TEXT,
|
||||||
|
utm_campaign TEXT,
|
||||||
|
device_type TEXT,
|
||||||
|
browser TEXT,
|
||||||
|
os TEXT,
|
||||||
|
country TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_page_views_path ON page_views(path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_page_views_created ON page_views(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_page_views_visitor ON page_views(visitor_hash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_analytics (
|
||||||
|
date TEXT PRIMARY KEY,
|
||||||
|
requests INTEGER DEFAULT 0,
|
||||||
|
page_views INTEGER DEFAULT 0,
|
||||||
|
unique_visitors INTEGER DEFAULT 0,
|
||||||
|
bandwidth INTEGER DEFAULT 0,
|
||||||
|
browsers TEXT,
|
||||||
|
os TEXT,
|
||||||
|
devices TEXT,
|
||||||
|
countries TEXT,
|
||||||
|
paths TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS members (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id),
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
tier TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
synced_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS components (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
compiled TEXT,
|
||||||
|
client_directive TEXT DEFAULT 'load',
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS plugins (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
language TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
wasm BLOB,
|
||||||
|
hooks TEXT NOT NULL,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
events TEXT NOT NULL,
|
||||||
|
secret TEXT,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_triggered_at TEXT,
|
||||||
|
last_status TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
response_code INTEGER,
|
||||||
|
response_body TEXT,
|
||||||
|
attempts INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, created_at DESC);
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := db.Exec(schema)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
||||||
|
slug UNINDEXED,
|
||||||
|
collection_slug UNINDEXED,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content,
|
||||||
|
type UNINDEXED,
|
||||||
|
url UNINDEXED,
|
||||||
|
date UNINDEXED
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init fts5: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
31
internal/tenant/sync.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemberSyncer struct {
|
||||||
|
pool *Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemberSyncer(pool *Pool) *MemberSyncer {
|
||||||
|
return &MemberSyncer{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemberSyncer) SyncMember(ctx context.Context, tenantID, userID, email, name, tier, status string, expiresAt *time.Time) error {
|
||||||
|
db, err := s.pool.Get(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := NewQueries(db)
|
||||||
|
return q.UpsertMember(ctx, &Member{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Name: name,
|
||||||
|
Tier: tier,
|
||||||
|
Status: status,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
117
internal/tenant/users.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT id, email, name, avatar_url, created_at FROM users WHERE id = ?`, id)
|
||||||
|
|
||||||
|
u, err := scanUser(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, `SELECT id, email, name, avatar_url, created_at FROM users WHERE email = ?`, email)
|
||||||
|
|
||||||
|
u, err := scanUser(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateUser(ctx context.Context, u *User) error {
|
||||||
|
if u.ID == "" {
|
||||||
|
u.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
_, err := q.db.ExecContext(ctx, `INSERT INTO users (id, email, name, avatar_url) VALUES (?, ?, ?, ?)`,
|
||||||
|
u.ID, u.Email, nullStr(u.Name), nullStr(u.AvatarURL))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ValidateSession(ctx context.Context, token string) (*Session, error) {
|
||||||
|
var s Session
|
||||||
|
var expiresAt string
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT token, user_id, expires_at FROM sessions WHERE token = ?`, token).
|
||||||
|
Scan(&s.Token, &s.UserID, &expiresAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ExpiresAt, _ = time.Parse(time.RFC3339, expiresAt)
|
||||||
|
if time.Now().After(s.ExpiresAt) {
|
||||||
|
q.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, token)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateSession(ctx context.Context, userID string) (*Session, error) {
|
||||||
|
token, err := generateToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
expires := time.Now().Add(30 * 24 * time.Hour)
|
||||||
|
expiresStr := expires.UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
_, err = q.db.ExecContext(ctx, `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`,
|
||||||
|
token, userID, expiresStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Session{
|
||||||
|
Token: token,
|
||||||
|
UserID: userID,
|
||||||
|
ExpiresAt: expires,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSession(ctx context.Context, token string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUser(s scanner) (User, error) {
|
||||||
|
var u User
|
||||||
|
var name, avatarURL, createdAt sql.NullString
|
||||||
|
|
||||||
|
err := s.Scan(&u.ID, &u.Email, &name, &avatarURL, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Name = name.String
|
||||||
|
u.AvatarURL = avatarURL.String
|
||||||
|
u.CreatedAt = parseTime(createdAt.String)
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
308
internal/tenant/webhooks.go
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Webhook struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
Secret string `json:"secret,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastTriggeredAt *time.Time `json:"last_triggered_at"`
|
||||||
|
LastStatus *string `json:"last_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookDelivery struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
WebhookID string `json:"webhook_id"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ResponseCode *int `json:"response_code"`
|
||||||
|
ResponseBody *string `json:"response_body"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookPayload struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CountWebhooks(ctx context.Context) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM webhooks`).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateWebhook(ctx context.Context, name, url string, events []string, secret string) (*Webhook, error) {
|
||||||
|
id := uuid.New().String()
|
||||||
|
eventsJSON, _ := json.Marshal(events)
|
||||||
|
|
||||||
|
_, err := q.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO webhooks (id, name, url, events, secret, enabled, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
|
||||||
|
`, id, name, url, string(eventsJSON), secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.GetWebhook(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetWebhook(ctx context.Context, id string) (*Webhook, error) {
|
||||||
|
var w Webhook
|
||||||
|
var eventsJSON string
|
||||||
|
var lastTriggeredAt, lastStatus sql.NullString
|
||||||
|
var createdAtStr string
|
||||||
|
|
||||||
|
err := q.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, name, url, events, secret, enabled, created_at, last_triggered_at, last_status
|
||||||
|
FROM webhooks WHERE id = ?
|
||||||
|
`, id).Scan(&w.ID, &w.Name, &w.URL, &eventsJSON, &w.Secret, &w.Enabled, &createdAtStr, &lastTriggeredAt, &lastStatus)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
json.Unmarshal([]byte(eventsJSON), &w.Events)
|
||||||
|
w.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
|
||||||
|
if lastTriggeredAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, lastTriggeredAt.String)
|
||||||
|
w.LastTriggeredAt = &t
|
||||||
|
}
|
||||||
|
if lastStatus.Valid {
|
||||||
|
w.LastStatus = &lastStatus.String
|
||||||
|
}
|
||||||
|
|
||||||
|
return &w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListWebhooks(ctx context.Context) ([]Webhook, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `
|
||||||
|
SELECT id, name, url, events, secret, enabled, created_at, last_triggered_at, last_status
|
||||||
|
FROM webhooks ORDER BY created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var webhooks []Webhook
|
||||||
|
for rows.Next() {
|
||||||
|
var w Webhook
|
||||||
|
var eventsJSON string
|
||||||
|
var lastTriggeredAt, lastStatus sql.NullString
|
||||||
|
var createdAtStr string
|
||||||
|
|
||||||
|
if err := rows.Scan(&w.ID, &w.Name, &w.URL, &eventsJSON, &w.Secret, &w.Enabled, &createdAtStr, &lastTriggeredAt, &lastStatus); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
json.Unmarshal([]byte(eventsJSON), &w.Events)
|
||||||
|
w.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
|
||||||
|
if lastTriggeredAt.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, lastTriggeredAt.String)
|
||||||
|
w.LastTriggeredAt = &t
|
||||||
|
}
|
||||||
|
if lastStatus.Valid {
|
||||||
|
w.LastStatus = &lastStatus.String
|
||||||
|
}
|
||||||
|
|
||||||
|
webhooks = append(webhooks, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
return webhooks, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateWebhook(ctx context.Context, id, name, url string, events []string, secret string, enabled bool) error {
|
||||||
|
eventsJSON, _ := json.Marshal(events)
|
||||||
|
|
||||||
|
_, err := q.db.ExecContext(ctx, `
|
||||||
|
UPDATE webhooks SET name = ?, url = ?, events = ?, secret = ?, enabled = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, name, url, string(eventsJSON), secret, enabled, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteWebhook(ctx context.Context, id string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, `DELETE FROM webhooks WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListWebhooksByEvent(ctx context.Context, event string) ([]Webhook, error) {
|
||||||
|
webhooks, err := q.ListWebhooks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []Webhook
|
||||||
|
for _, w := range webhooks {
|
||||||
|
if !w.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range w.Events {
|
||||||
|
if e == event {
|
||||||
|
result = append(result, w)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) TriggerWebhooks(ctx context.Context, event string, data any, baseURL string) {
|
||||||
|
webhooks, err := q.ListWebhooksByEvent(ctx, event)
|
||||||
|
if err != nil || len(webhooks) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := WebhookPayload{
|
||||||
|
Event: event,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
payloadJSON, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
for _, w := range webhooks {
|
||||||
|
go q.deliverWebhook(ctx, w, event, payloadJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) deliverWebhook(ctx context.Context, w Webhook, event string, payloadJSON []byte) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", w.URL, bytes.NewReader(payloadJSON))
|
||||||
|
if err != nil {
|
||||||
|
q.logDelivery(ctx, w.ID, event, string(payloadJSON), "failed", nil, stringPtr(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "WriteKit-Webhook/1.0")
|
||||||
|
|
||||||
|
if w.Secret != "" {
|
||||||
|
mac := hmac.New(sha256.New, []byte(w.Secret))
|
||||||
|
mac.Write(payloadJSON)
|
||||||
|
signature := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
req.Header.Set("X-WriteKit-Signature", signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
q.logDelivery(ctx, w.ID, event, string(payloadJSON), "failed", nil, stringPtr(err.Error()))
|
||||||
|
q.updateWebhookStatus(ctx, w.ID, "failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var respBody string
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, _ := resp.Body.Read(buf)
|
||||||
|
respBody = string(buf[:n])
|
||||||
|
|
||||||
|
status := "success"
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
q.logDelivery(ctx, w.ID, event, string(payloadJSON), status, &resp.StatusCode, &respBody)
|
||||||
|
q.updateWebhookStatus(ctx, w.ID, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) logDelivery(ctx context.Context, webhookID, event, payload, status string, responseCode *int, responseBody *string) {
|
||||||
|
q.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO webhook_deliveries (webhook_id, event, payload, status, response_code, response_body, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
`, webhookID, event, truncate(payload, 1024), status, responseCode, truncate(ptrToString(responseBody), 1024))
|
||||||
|
|
||||||
|
// Cleanup old deliveries - keep last 50 per webhook
|
||||||
|
q.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM webhook_deliveries
|
||||||
|
WHERE webhook_id = ? AND id NOT IN (
|
||||||
|
SELECT id FROM webhook_deliveries WHERE webhook_id = ?
|
||||||
|
ORDER BY created_at DESC LIMIT 50
|
||||||
|
)
|
||||||
|
`, webhookID, webhookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) updateWebhookStatus(ctx context.Context, webhookID, status string) {
|
||||||
|
q.db.ExecContext(ctx, `
|
||||||
|
UPDATE webhooks SET last_triggered_at = CURRENT_TIMESTAMP, last_status = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, status, webhookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListWebhookDeliveries(ctx context.Context, webhookID string) ([]WebhookDelivery, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, `
|
||||||
|
SELECT id, webhook_id, event, payload, status, response_code, response_body, attempts, created_at
|
||||||
|
FROM webhook_deliveries
|
||||||
|
WHERE webhook_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`, webhookID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var deliveries []WebhookDelivery
|
||||||
|
for rows.Next() {
|
||||||
|
var d WebhookDelivery
|
||||||
|
var createdAtStr string
|
||||||
|
var respCode sql.NullInt64
|
||||||
|
var respBody sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(&d.ID, &d.WebhookID, &d.Event, &d.Payload, &d.Status, &respCode, &respBody, &d.Attempts, &createdAtStr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
|
||||||
|
if respCode.Valid {
|
||||||
|
code := int(respCode.Int64)
|
||||||
|
d.ResponseCode = &code
|
||||||
|
}
|
||||||
|
if respBody.Valid {
|
||||||
|
d.ResponseBody = &respBody.String
|
||||||
|
}
|
||||||
|
|
||||||
|
deliveries = append(deliveries, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return deliveries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max]
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrToString(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
50
main.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/writekitapp/writekit/internal/db"
|
||||||
|
"github.com/writekitapp/writekit/internal/server"
|
||||||
|
"github.com/writekitapp/writekit/internal/storage"
|
||||||
|
"github.com/writekitapp/writekit/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
database, err := db.Connect("./internal/db/migrations")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("postgres: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
dataDir := os.Getenv("DATA_DIR")
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = "./data"
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := tenant.NewPool(dataDir)
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
cache := tenant.NewCache()
|
||||||
|
defer cache.Close()
|
||||||
|
|
||||||
|
var storageClient storage.Client
|
||||||
|
if os.Getenv("R2_ACCOUNT_ID") != "" {
|
||||||
|
storageClient, err = storage.NewR2Client()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("storage: %v (continuing without storage)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := server.New(database, pool, cache, storageClient)
|
||||||
|
srv.StartAnalyticsSync()
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("listening on :%s", port)
|
||||||
|
log.Fatal(http.ListenAndServe(":"+port, srv))
|
||||||
|
}
|
||||||
20
studio/embed.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package studio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed dist/*
|
||||||
|
var distFS embed.FS
|
||||||
|
|
||||||
|
func Handler() http.Handler {
|
||||||
|
sub, _ := fs.Sub(distFS, "dist")
|
||||||
|
return http.FileServer(http.FS(sub))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Read(name string) ([]byte, error) {
|
||||||
|
return distFS.ReadFile(path.Join("dist", name))
|
||||||
|
}
|
||||||
16
studio/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WriteKit Studio</title>
|
||||||
|
<style>
|
||||||
|
:root { --accent: #3b82f6; }
|
||||||
|
@media (prefers-color-scheme: dark) { html { color-scheme: dark; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3732
studio/package-lock.json
generated
Normal file
49
studio/package.json
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"name": "writekit-studio",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify-json/logos": "^1.2.10",
|
||||||
|
"@iconify-json/lucide": "^1.2.82",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@nanostores/query": "^0.3.4",
|
||||||
|
"@nanostores/react": "^1.0.0",
|
||||||
|
"@nanostores/router": "^1.0.0",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^3.15.1",
|
||||||
|
"@tiptap/extension-image": "^3.15.1",
|
||||||
|
"@tiptap/extension-link": "^3.15.1",
|
||||||
|
"@tiptap/extension-placeholder": "^3.15.1",
|
||||||
|
"@tiptap/extension-table": "^3.15.1",
|
||||||
|
"@tiptap/extension-task-item": "^3.15.1",
|
||||||
|
"@tiptap/extension-task-list": "^3.15.1",
|
||||||
|
"@tiptap/markdown": "^3.15.1",
|
||||||
|
"@tiptap/pm": "^3.15.1",
|
||||||
|
"@tiptap/react": "^3.15.1",
|
||||||
|
"@tiptap/starter-kit": "^3.15.1",
|
||||||
|
"@tiptap/suggestion": "^3.15.2",
|
||||||
|
"@unocss/reset": "^66.5.12",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
|
"nanostores": "^1.1.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
|
"unocss": "^66.5.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/simple-icons": "^1.2.65",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^7.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
studio/src/App.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $router } from './stores/router'
|
||||||
|
import { Sidebar, Header } from './components/layout'
|
||||||
|
import { Toasts } from './components/ui'
|
||||||
|
import { Icons } from './components/shared/Icons'
|
||||||
|
import {
|
||||||
|
PostsPageSkeleton,
|
||||||
|
AnalyticsPageSkeleton,
|
||||||
|
SettingsPageSkeleton,
|
||||||
|
GeneralPageSkeleton,
|
||||||
|
DesignPageSkeleton,
|
||||||
|
EngagementPageSkeleton,
|
||||||
|
APIPageSkeleton,
|
||||||
|
BillingPageSkeleton,
|
||||||
|
HomePageSkeleton,
|
||||||
|
} from './components/shared'
|
||||||
|
|
||||||
|
const HomePage = lazy(() => import('./pages/HomePage'))
|
||||||
|
const PostsPage = lazy(() => import('./pages/PostsPage'))
|
||||||
|
const PostEditorPage = lazy(() => import('./pages/PostEditorPage'))
|
||||||
|
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'))
|
||||||
|
const GeneralPage = lazy(() => import('./pages/GeneralPage'))
|
||||||
|
const DesignPage = lazy(() => import('./pages/DesignPage'))
|
||||||
|
const DomainPage = lazy(() => import('./pages/DomainPage'))
|
||||||
|
const EngagementPage = lazy(() => import('./pages/EngagementPage'))
|
||||||
|
const MonetizationPage = lazy(() => import('./pages/MonetizationPage'))
|
||||||
|
const PluginsPage = lazy(() => import('./pages/PluginsPage'))
|
||||||
|
const APIPage = lazy(() => import('./pages/APIPage'))
|
||||||
|
const DataPage = lazy(() => import('./pages/DataPage'))
|
||||||
|
const BillingPage = lazy(() => import('./pages/BillingPage'))
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
home: { Component: HomePage, Skeleton: HomePageSkeleton },
|
||||||
|
postNew: { Component: PostEditorPage, Skeleton: SettingsPageSkeleton, fullWidth: true },
|
||||||
|
postEdit: { Component: PostEditorPage, Skeleton: SettingsPageSkeleton, fullWidth: true },
|
||||||
|
posts: { Component: PostsPage, Skeleton: PostsPageSkeleton },
|
||||||
|
analytics: { Component: AnalyticsPage, Skeleton: AnalyticsPageSkeleton },
|
||||||
|
general: { Component: GeneralPage, Skeleton: GeneralPageSkeleton },
|
||||||
|
design: { Component: DesignPage, Skeleton: DesignPageSkeleton },
|
||||||
|
domain: { Component: DomainPage, Skeleton: SettingsPageSkeleton },
|
||||||
|
engagement: { Component: EngagementPage, Skeleton: EngagementPageSkeleton },
|
||||||
|
monetization: { Component: MonetizationPage, Skeleton: SettingsPageSkeleton },
|
||||||
|
plugins: { Component: PluginsPage, Skeleton: SettingsPageSkeleton },
|
||||||
|
api: { Component: APIPage, Skeleton: APIPageSkeleton },
|
||||||
|
data: { Component: DataPage, Skeleton: SettingsPageSkeleton },
|
||||||
|
billing: { Component: BillingPage, Skeleton: BillingPageSkeleton },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function Router() {
|
||||||
|
const page = useStore($router)
|
||||||
|
const routeKey = page?.route ?? 'home'
|
||||||
|
|
||||||
|
const route = routes[routeKey as keyof typeof routes] || routes.home
|
||||||
|
const { Component: PageComponent, Skeleton } = route
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
|
<PageComponent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const page = useStore($router)
|
||||||
|
const routeKey = page?.route ?? 'home'
|
||||||
|
const route = routes[routeKey as keyof typeof routes]
|
||||||
|
const isFullWidth = route && 'fullWidth' in route && route.fullWidth
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg text-text font-sans antialiased">
|
||||||
|
<Header className={isFullWidth ? 'hidden' : ''} />
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
target="_blank"
|
||||||
|
className={`fixed top-4 right-4 z-40 items-center gap-2 px-3 py-1.5 text-xs text-muted border border-border bg-surface hover:text-text hover:border-muted transition-colors ${isFullWidth ? 'hidden' : 'hidden lg:flex'}`}
|
||||||
|
>
|
||||||
|
<Icons.ExternalLink className="text-sm" />
|
||||||
|
<span>View Site</span>
|
||||||
|
</a>
|
||||||
|
<div className="flex">
|
||||||
|
<div className={`fixed left-0 top-0 h-screen ${isFullWidth ? 'hidden' : 'hidden lg:block'}`}>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
<main className={isFullWidth ? 'flex-1' : 'flex-1 lg:ml-56 p-6 lg:p-10'}>
|
||||||
|
<Router />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<Toasts />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
studio/src/api.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import type { Post, Settings, InteractionConfig, Asset, APIKey, AnalyticsSummary } from './types'
|
||||||
|
|
||||||
|
const BASE = '/api/studio'
|
||||||
|
|
||||||
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new Error(error.error || 'Request failed')
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
posts: {
|
||||||
|
list: () => request<Post[]>('/posts'),
|
||||||
|
get: (slug: string) => request<Post>(`/posts/${slug}`),
|
||||||
|
create: (data: Partial<Post>) => request<Post>('/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
update: (slug: string, data: Partial<Post>) => request<Post>(`/posts/${slug}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
delete: (slug: string) => request<void>(`/posts/${slug}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
get: () => request<Settings>('/settings'),
|
||||||
|
update: (data: Settings) => request<{ success: boolean }>('/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
interactions: {
|
||||||
|
get: () => request<InteractionConfig>('/interaction-config'),
|
||||||
|
update: (data: Partial<InteractionConfig>) => request<InteractionConfig>('/interaction-config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
assets: {
|
||||||
|
list: () => request<Asset[]>('/assets'),
|
||||||
|
upload: async (file: File): Promise<Asset> => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const res = await fetch(`${BASE}/assets`, { method: 'POST', body: form })
|
||||||
|
if (!res.ok) throw new Error('Upload failed')
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
delete: (id: string) => request<void>(`/assets/${id}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
|
||||||
|
apiKeys: {
|
||||||
|
list: () => request<APIKey[]>('/api-keys'),
|
||||||
|
create: (name: string) => request<APIKey>('/api-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}),
|
||||||
|
delete: (key: string) => request<void>(`/api-keys/${key}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
|
||||||
|
analytics: {
|
||||||
|
get: (days = 30) => request<AnalyticsSummary>(`/analytics?days=${days}`),
|
||||||
|
getPost: (slug: string, days = 30) => request<AnalyticsSummary>(`/analytics/posts/${slug}?days=${days}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
198
studio/src/components/editor/MetadataPanel.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $editorPost, $versions, $isNewPost, restoreVersion } from '../../stores/editor'
|
||||||
|
import { addToast } from '../../stores/app'
|
||||||
|
import { Icons } from '../shared/Icons'
|
||||||
|
import { Input, Textarea, Toggle } from '../ui'
|
||||||
|
|
||||||
|
interface MetadataPanelProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetadataPanel({ onClose }: MetadataPanelProps) {
|
||||||
|
const post = useStore($editorPost)
|
||||||
|
const versions = useStore($versions)
|
||||||
|
const isNew = useStore($isNewPost)
|
||||||
|
const [restoringId, setRestoringId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const handleTagsChange = (value: string) => {
|
||||||
|
const tags = value.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
$editorPost.setKey('tags', tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (versionId: number) => {
|
||||||
|
if (!confirm('Restore this version? Your current draft will be replaced.')) return
|
||||||
|
setRestoringId(versionId)
|
||||||
|
const success = await restoreVersion(versionId)
|
||||||
|
setRestoringId(null)
|
||||||
|
if (success) {
|
||||||
|
addToast('Version restored', 'success')
|
||||||
|
} else {
|
||||||
|
addToast('Failed to restore version', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-80 flex-none border-l border-border bg-surface overflow-y-auto">
|
||||||
|
<div className="p-4 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium">Post Settings</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-muted hover:text-text rounded"
|
||||||
|
>
|
||||||
|
<Icons.Close className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-5">
|
||||||
|
{/* Slug */}
|
||||||
|
<div>
|
||||||
|
<label className="label">URL Slug</label>
|
||||||
|
<Input
|
||||||
|
value={post.slug}
|
||||||
|
onChange={v => $editorPost.setKey('slug', v.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
|
||||||
|
placeholder="my-post-title"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted mt-1">/posts/{post.slug || 'my-post-title'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="label">Description</label>
|
||||||
|
<Textarea
|
||||||
|
value={post.description}
|
||||||
|
onChange={v => $editorPost.setKey('description', v)}
|
||||||
|
placeholder="Brief summary for SEO and social shares..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover Image */}
|
||||||
|
<div>
|
||||||
|
<label className="label">Cover Image</label>
|
||||||
|
{post.cover_image ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative aspect-video bg-border/50 overflow-hidden rounded">
|
||||||
|
<img
|
||||||
|
src={post.cover_image}
|
||||||
|
alt="Cover"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => $editorPost.setKey('cover_image', '')}
|
||||||
|
className="absolute top-2 right-2 p-1 bg-bg/80 hover:bg-bg rounded text-muted hover:text-text transition-colors"
|
||||||
|
title="Remove cover"
|
||||||
|
>
|
||||||
|
<Icons.Close className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={post.cover_image || ''}
|
||||||
|
onChange={v => $editorPost.setKey('cover_image', v)}
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted mt-1">Used for social sharing and post headers</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div>
|
||||||
|
<label className="label">Date</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={post.date}
|
||||||
|
onChange={v => $editorPost.setKey('date', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<label className="label">Tags</label>
|
||||||
|
<Input
|
||||||
|
value={post.tags.join(', ')}
|
||||||
|
onChange={handleTagsChange}
|
||||||
|
placeholder="react, typescript, tutorial"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted mt-1">Comma-separated</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<h4 className="text-xs font-medium text-muted uppercase tracking-wide mb-3">Publishing</h4>
|
||||||
|
|
||||||
|
{/* Draft Toggle */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">Draft</div>
|
||||||
|
<div className="text-xs text-muted">Not visible to readers</div>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={post.draft}
|
||||||
|
onChange={v => $editorPost.setKey('draft', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members Only Toggle */}
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">Members only</div>
|
||||||
|
<div className="text-xs text-muted">Requires login to view</div>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={post.members_only}
|
||||||
|
onChange={v => $editorPost.setKey('members_only', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version History */}
|
||||||
|
{!isNew && versions.length > 0 && (
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<h4 className="text-xs font-medium text-muted uppercase tracking-wide mb-3">
|
||||||
|
<Icons.History className="w-3.5 h-3.5 inline-block mr-1.5 -mt-0.5" />
|
||||||
|
Version History
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{versions.map((version) => (
|
||||||
|
<div
|
||||||
|
key={version.id}
|
||||||
|
className="flex items-center justify-between py-2 px-2 -mx-2 rounded hover:bg-bg"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm truncate">{version.title}</div>
|
||||||
|
<div className="text-xs text-muted">{formatDate(version.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="px-2 py-1 text-xs text-muted hover:text-text hover:bg-bg/50 rounded transition-colors disabled:opacity-50"
|
||||||
|
onClick={() => handleRestore(version.id)}
|
||||||
|
disabled={restoringId !== null}
|
||||||
|
>
|
||||||
|
{restoringId === version.id ? (
|
||||||
|
<Icons.Loader className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Restore'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
722
studio/src/components/editor/PluginEditor.tsx
Normal file
|
|
@ -0,0 +1,722 @@
|
||||||
|
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||||
|
import Editor, { type Monaco } from '@monaco-editor/react'
|
||||||
|
import type { editor, languages, IDisposable, Position } from 'monaco-editor'
|
||||||
|
|
||||||
|
interface PluginEditorProps {
|
||||||
|
language: 'typescript' | 'go'
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
height?: string
|
||||||
|
secretKeys?: string[]
|
||||||
|
hook?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SDKFunction {
|
||||||
|
name: string
|
||||||
|
signature: string
|
||||||
|
insertText: string
|
||||||
|
documentation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SDKNamespace {
|
||||||
|
functions?: SDKFunction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SDKField {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
doc?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SDKEventType {
|
||||||
|
fields: SDKField[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SDKSchema {
|
||||||
|
Runner: SDKNamespace
|
||||||
|
events?: Record<string, SDKEventType>
|
||||||
|
nestedTypes?: Record<string, SDKField[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANGUAGE_MAP: Record<string, string> = {
|
||||||
|
typescript: 'typescript',
|
||||||
|
go: 'go',
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript SDK type definitions - well-formatted for hover display
|
||||||
|
const getTypeScriptSDK = (secretKeys: string[]) => {
|
||||||
|
const secretsType = secretKeys.length > 0
|
||||||
|
? `{\n${secretKeys.map(k => ` /** Secret: ${k} */\n ${k}: string;`).join('\n')}\n }`
|
||||||
|
: 'Record<string, string>'
|
||||||
|
|
||||||
|
return `
|
||||||
|
/**
|
||||||
|
* Runner provides all plugin capabilities
|
||||||
|
*/
|
||||||
|
declare namespace Runner {
|
||||||
|
/** Log a message (visible in plugin logs) */
|
||||||
|
function log(message: string): void;
|
||||||
|
|
||||||
|
/** Make an HTTP request to external services */
|
||||||
|
function httpRequest(options: HttpRequestOptions): HttpResponse;
|
||||||
|
|
||||||
|
/** Access your configured secrets */
|
||||||
|
const secrets: ${secretsType};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for making HTTP requests */
|
||||||
|
interface HttpRequestOptions {
|
||||||
|
/** The URL to request */
|
||||||
|
url: string;
|
||||||
|
/** HTTP method (default: GET) */
|
||||||
|
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||||
|
/** HTTP headers to send */
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
/** Request body (for POST/PUT/PATCH) */
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from an HTTP request */
|
||||||
|
interface HttpResponse {
|
||||||
|
/** HTTP status code */
|
||||||
|
status: number;
|
||||||
|
/** Response headers */
|
||||||
|
headers: Record<string, string>;
|
||||||
|
/** Response body as string */
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result for validation hooks (comment.validate, etc.) */
|
||||||
|
interface ValidationResult {
|
||||||
|
/** Whether the action is allowed */
|
||||||
|
allowed: boolean;
|
||||||
|
/** Reason for rejection (shown to user if allowed=false) */
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blog post information */
|
||||||
|
interface Post {
|
||||||
|
/** URL-safe identifier */
|
||||||
|
slug: string;
|
||||||
|
/** Post title */
|
||||||
|
title: string;
|
||||||
|
/** Full URL to the post */
|
||||||
|
url: string;
|
||||||
|
/** Short excerpt of the content */
|
||||||
|
excerpt: string;
|
||||||
|
/** ISO date when published */
|
||||||
|
publishedAt: string;
|
||||||
|
/** ISO date when last updated */
|
||||||
|
updatedAt?: string;
|
||||||
|
/** Post tags */
|
||||||
|
tags: string[];
|
||||||
|
/** Estimated reading time in minutes */
|
||||||
|
readingTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Author information */
|
||||||
|
interface Author {
|
||||||
|
/** Author's display name */
|
||||||
|
name: string;
|
||||||
|
/** Author's email */
|
||||||
|
email: string;
|
||||||
|
/** URL to author's avatar image */
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blog information */
|
||||||
|
interface Blog {
|
||||||
|
/** Blog name */
|
||||||
|
name: string;
|
||||||
|
/** Blog URL */
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a post is published
|
||||||
|
* @example
|
||||||
|
* export function onPostPublished(event: PostPublishedEvent): void {
|
||||||
|
* Runner.log(\`Published: \${event.post.title}\`);
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
interface PostPublishedEvent {
|
||||||
|
/** The published post */
|
||||||
|
post: Post;
|
||||||
|
/** The post author */
|
||||||
|
author: Author;
|
||||||
|
/** The blog where it was published */
|
||||||
|
blog: Blog;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a post is updated
|
||||||
|
*/
|
||||||
|
interface PostUpdatedEvent {
|
||||||
|
/** The updated post */
|
||||||
|
post: Post;
|
||||||
|
/** The author who made the update */
|
||||||
|
author: Author;
|
||||||
|
/** What changed in this update */
|
||||||
|
changes: {
|
||||||
|
/** Title change (old and new values) */
|
||||||
|
title?: { old: string; new: string };
|
||||||
|
/** Whether content was modified */
|
||||||
|
content?: boolean;
|
||||||
|
/** Tags that were added or removed */
|
||||||
|
tags?: { added: string[]; removed: string[] };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comment information */
|
||||||
|
interface Comment {
|
||||||
|
/** Unique comment ID */
|
||||||
|
id: string;
|
||||||
|
/** Comment content (may contain markdown) */
|
||||||
|
content: string;
|
||||||
|
/** Commenter's display name */
|
||||||
|
authorName: string;
|
||||||
|
/** Commenter's email */
|
||||||
|
authorEmail: string;
|
||||||
|
/** Slug of the post being commented on */
|
||||||
|
postSlug: string;
|
||||||
|
/** Parent comment ID for replies */
|
||||||
|
parentId?: string;
|
||||||
|
/** ISO date when created */
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a comment is created
|
||||||
|
*/
|
||||||
|
interface CommentCreatedEvent {
|
||||||
|
/** The new comment */
|
||||||
|
comment: Comment;
|
||||||
|
/** The post being commented on */
|
||||||
|
post: {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Member/subscriber information */
|
||||||
|
interface Member {
|
||||||
|
/** Member's email */
|
||||||
|
email: string;
|
||||||
|
/** Member's name (if provided) */
|
||||||
|
name?: string;
|
||||||
|
/** ISO date when subscribed */
|
||||||
|
subscribedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscription tier information */
|
||||||
|
interface Tier {
|
||||||
|
/** Tier name (e.g., "Free", "Premium") */
|
||||||
|
name: string;
|
||||||
|
/** Monthly price in cents (0 for free tier) */
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a member subscribes
|
||||||
|
*/
|
||||||
|
interface MemberSubscribedEvent {
|
||||||
|
/** The new member */
|
||||||
|
member: Member;
|
||||||
|
/** The subscription tier they signed up for */
|
||||||
|
tier: Tier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when an asset (image, file) is uploaded
|
||||||
|
*/
|
||||||
|
interface AssetUploadedEvent {
|
||||||
|
/** Unique asset ID */
|
||||||
|
id: string;
|
||||||
|
/** Public URL to access the asset */
|
||||||
|
url: string;
|
||||||
|
/** MIME type (e.g., "image/png") */
|
||||||
|
contentType: string;
|
||||||
|
/** File size in bytes */
|
||||||
|
size: number;
|
||||||
|
/** Image width in pixels (for images only) */
|
||||||
|
width?: number;
|
||||||
|
/** Image height in pixels (for images only) */
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when analytics data is synced
|
||||||
|
*/
|
||||||
|
interface AnalyticsSyncEvent {
|
||||||
|
/** Time period for this analytics data */
|
||||||
|
period: {
|
||||||
|
/** ISO date for period start */
|
||||||
|
start: string;
|
||||||
|
/** ISO date for period end */
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
/** Total pageviews in this period */
|
||||||
|
pageviews: number;
|
||||||
|
/** Unique visitors in this period */
|
||||||
|
visitors: number;
|
||||||
|
/** Top pages by view count */
|
||||||
|
topPages: Array<{
|
||||||
|
/** Page path (e.g., "/posts/my-post") */
|
||||||
|
path: string;
|
||||||
|
/** Number of views */
|
||||||
|
views: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for comment validation hook
|
||||||
|
* Return ValidationResult to allow or reject the comment
|
||||||
|
*/
|
||||||
|
interface CommentInput {
|
||||||
|
/** Comment content to validate */
|
||||||
|
content: string;
|
||||||
|
/** Commenter's name */
|
||||||
|
authorName: string;
|
||||||
|
/** Commenter's email */
|
||||||
|
authorEmail: string;
|
||||||
|
/** Post slug being commented on */
|
||||||
|
postSlug: string;
|
||||||
|
/** Parent comment ID (for replies) */
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for content rendering hook
|
||||||
|
*/
|
||||||
|
interface ContentRenderInput {
|
||||||
|
/** HTML content to transform */
|
||||||
|
html: string;
|
||||||
|
/** Post metadata */
|
||||||
|
post: {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output for content rendering hook
|
||||||
|
*/
|
||||||
|
interface ContentRenderOutput {
|
||||||
|
/** Transformed HTML content */
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const defineWriteKitTheme = (monaco: Monaco) => {
|
||||||
|
monaco.editor.defineTheme('writekit-dark', {
|
||||||
|
base: 'vs-dark',
|
||||||
|
inherit: false,
|
||||||
|
rules: [
|
||||||
|
// Base text - warm gray for readability
|
||||||
|
{ token: '', foreground: 'c4c4c4' },
|
||||||
|
|
||||||
|
// Comments - muted, italic
|
||||||
|
{ token: 'comment', foreground: '525252', fontStyle: 'italic' },
|
||||||
|
{ token: 'comment.doc', foreground: '5c5c5c', fontStyle: 'italic' },
|
||||||
|
|
||||||
|
// Keywords - subtle off-white, not too bright
|
||||||
|
{ token: 'keyword', foreground: 'd4d4d4' },
|
||||||
|
{ token: 'keyword.control', foreground: 'd4d4d4' },
|
||||||
|
{ token: 'keyword.operator', foreground: '9ca3af' },
|
||||||
|
|
||||||
|
// Strings - emerald accent (WriteKit brand)
|
||||||
|
{ token: 'string', foreground: '34d399' },
|
||||||
|
{ token: 'string.key', foreground: 'a3a3a3' },
|
||||||
|
{ token: 'string.escape', foreground: '6ee7b7' },
|
||||||
|
|
||||||
|
// Numbers - muted amber for contrast
|
||||||
|
{ token: 'number', foreground: 'fbbf24' },
|
||||||
|
{ token: 'number.hex', foreground: 'f59e0b' },
|
||||||
|
|
||||||
|
// Types/Interfaces - cyan accent
|
||||||
|
{ token: 'type', foreground: '22d3ee' },
|
||||||
|
{ token: 'type.identifier', foreground: '22d3ee' },
|
||||||
|
{ token: 'class', foreground: '22d3ee' },
|
||||||
|
{ token: 'interface', foreground: '67e8f9' },
|
||||||
|
{ token: 'namespace', foreground: '22d3ee' },
|
||||||
|
|
||||||
|
// Functions - clean white for emphasis
|
||||||
|
{ token: 'function', foreground: 'f5f5f5' },
|
||||||
|
{ token: 'function.declaration', foreground: 'ffffff' },
|
||||||
|
{ token: 'method', foreground: 'f5f5f5' },
|
||||||
|
|
||||||
|
// Variables and parameters
|
||||||
|
{ token: 'variable', foreground: 'c4c4c4' },
|
||||||
|
{ token: 'variable.predefined', foreground: '67e8f9' },
|
||||||
|
{ token: 'parameter', foreground: 'e5e5e5' },
|
||||||
|
{ token: 'property', foreground: 'a3a3a3' },
|
||||||
|
|
||||||
|
// Constants - emerald like strings
|
||||||
|
{ token: 'constant', foreground: '34d399' },
|
||||||
|
{ token: 'constant.language', foreground: 'fb923c' },
|
||||||
|
|
||||||
|
// Operators and delimiters - subdued
|
||||||
|
{ token: 'operator', foreground: '737373' },
|
||||||
|
{ token: 'delimiter', foreground: '525252' },
|
||||||
|
{ token: 'delimiter.bracket', foreground: '737373' },
|
||||||
|
|
||||||
|
// HTML/JSX
|
||||||
|
{ token: 'tag', foreground: '22d3ee' },
|
||||||
|
{ token: 'attribute.name', foreground: 'a3a3a3' },
|
||||||
|
{ token: 'attribute.value', foreground: '34d399' },
|
||||||
|
|
||||||
|
// Regex
|
||||||
|
{ token: 'regexp', foreground: 'f472b6' },
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
// Editor chrome - slightly lighter than pure dark
|
||||||
|
'editor.background': '#1c1c1e',
|
||||||
|
'editor.foreground': '#c4c4c4',
|
||||||
|
|
||||||
|
// Line highlighting - subtle
|
||||||
|
'editor.lineHighlightBackground': '#232326',
|
||||||
|
'editor.lineHighlightBorder': '#00000000',
|
||||||
|
|
||||||
|
// Selection - emerald tint
|
||||||
|
'editor.selectionBackground': '#10b98135',
|
||||||
|
'editor.inactiveSelectionBackground': '#10b98118',
|
||||||
|
'editor.selectionHighlightBackground': '#10b98115',
|
||||||
|
|
||||||
|
// Cursor - emerald brand color
|
||||||
|
'editorCursor.foreground': '#10b981',
|
||||||
|
|
||||||
|
// Line numbers - muted
|
||||||
|
'editorLineNumber.foreground': '#3f3f46',
|
||||||
|
'editorLineNumber.activeForeground': '#71717a',
|
||||||
|
|
||||||
|
// Indent guides
|
||||||
|
'editorIndentGuide.background1': '#27272a',
|
||||||
|
'editorIndentGuide.activeBackground1': '#3f3f46',
|
||||||
|
|
||||||
|
// Bracket matching
|
||||||
|
'editorBracketMatch.background': '#10b98125',
|
||||||
|
'editorBracketMatch.border': '#10b98170',
|
||||||
|
|
||||||
|
// Whitespace
|
||||||
|
'editorWhitespace.foreground': '#2e2e33',
|
||||||
|
|
||||||
|
// Scrollbar - subtle
|
||||||
|
'scrollbarSlider.background': '#3f3f4660',
|
||||||
|
'scrollbarSlider.hoverBackground': '#52525b80',
|
||||||
|
'scrollbarSlider.activeBackground': '#71717a80',
|
||||||
|
|
||||||
|
// Widgets (autocomplete, hover)
|
||||||
|
'editorWidget.background': '#1e1e21',
|
||||||
|
'editorWidget.border': '#3f3f46',
|
||||||
|
'editorSuggestWidget.background': '#1e1e21',
|
||||||
|
'editorSuggestWidget.border': '#3f3f46',
|
||||||
|
'editorSuggestWidget.selectedBackground': '#2a2a2e',
|
||||||
|
'editorSuggestWidget.highlightForeground': '#34d399',
|
||||||
|
'editorHoverWidget.background': '#1e1e21',
|
||||||
|
'editorHoverWidget.border': '#3f3f46',
|
||||||
|
|
||||||
|
// Input fields
|
||||||
|
'input.background': '#1e1e21',
|
||||||
|
'input.border': '#3f3f46',
|
||||||
|
'input.foreground': '#c4c4c4',
|
||||||
|
|
||||||
|
// Focus
|
||||||
|
'focusBorder': '#10b981',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginEditor({ language, value, onChange, height = '500px', secretKeys = [], hook = 'post.published' }: PluginEditorProps) {
|
||||||
|
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
|
||||||
|
const monacoRef = useRef<Monaco | null>(null)
|
||||||
|
const disposablesRef = useRef<IDisposable[]>([])
|
||||||
|
const [sdkSchema, setSdkSchema] = useState<SDKSchema | null>(null)
|
||||||
|
|
||||||
|
// Fetch SDK schema when language changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/studio/sdk?language=${language}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setSdkSchema)
|
||||||
|
.catch(() => {})
|
||||||
|
}, [language])
|
||||||
|
|
||||||
|
// Helper to get fields for a type from nested types or event fields
|
||||||
|
const getFieldsForType = useCallback((typeName: string): SDKField[] => {
|
||||||
|
if (!sdkSchema) return []
|
||||||
|
// Check nested types first
|
||||||
|
if (sdkSchema.nestedTypes?.[typeName]) {
|
||||||
|
return sdkSchema.nestedTypes[typeName]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}, [sdkSchema])
|
||||||
|
|
||||||
|
// Track TypeScript lib disposable separately
|
||||||
|
const tsLibDisposableRef = useRef<IDisposable | null>(null)
|
||||||
|
|
||||||
|
// Update TypeScript SDK when secrets change
|
||||||
|
useEffect(() => {
|
||||||
|
const monaco = monacoRef.current
|
||||||
|
if (!monaco || language !== 'typescript') return
|
||||||
|
|
||||||
|
// Dispose previous SDK lib if it exists
|
||||||
|
if (tsLibDisposableRef.current) {
|
||||||
|
tsLibDisposableRef.current.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add updated SDK with new secrets
|
||||||
|
const sdk = getTypeScriptSDK(secretKeys)
|
||||||
|
tsLibDisposableRef.current = monaco.languages.typescript.typescriptDefaults.addExtraLib(
|
||||||
|
sdk,
|
||||||
|
'file:///node_modules/@writekit/sdk/index.d.ts'
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (tsLibDisposableRef.current) {
|
||||||
|
tsLibDisposableRef.current.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [language, secretKeys])
|
||||||
|
|
||||||
|
// Register completion providers when SDK schema or language changes
|
||||||
|
useEffect(() => {
|
||||||
|
const monaco = monacoRef.current
|
||||||
|
if (!monaco || !sdkSchema) return
|
||||||
|
|
||||||
|
// Dispose previous providers
|
||||||
|
disposablesRef.current.forEach(d => d.dispose())
|
||||||
|
disposablesRef.current = []
|
||||||
|
|
||||||
|
// Register Go completion providers
|
||||||
|
if (language === 'go') {
|
||||||
|
const langId = LANGUAGE_MAP[language]
|
||||||
|
|
||||||
|
// Completion provider for Runner. and event.
|
||||||
|
const completionProvider = monaco.languages.registerCompletionItemProvider(langId, {
|
||||||
|
triggerCharacters: ['.'],
|
||||||
|
provideCompletionItems: (model: editor.ITextModel, position: Position) => {
|
||||||
|
const textBefore = model.getValueInRange({
|
||||||
|
startLineNumber: position.lineNumber,
|
||||||
|
startColumn: 1,
|
||||||
|
endLineNumber: position.lineNumber,
|
||||||
|
endColumn: position.column
|
||||||
|
})
|
||||||
|
|
||||||
|
const suggestions: languages.CompletionItem[] = []
|
||||||
|
const range = {
|
||||||
|
startLineNumber: position.lineNumber,
|
||||||
|
startColumn: position.column,
|
||||||
|
endLineNumber: position.lineNumber,
|
||||||
|
endColumn: position.column
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner. completions
|
||||||
|
if (textBefore.endsWith('Runner.')) {
|
||||||
|
// Add functions
|
||||||
|
sdkSchema.Runner.functions?.forEach(fn => {
|
||||||
|
suggestions.push({
|
||||||
|
label: fn.name,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Method,
|
||||||
|
insertText: fn.insertText,
|
||||||
|
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||||
|
documentation: { value: `**${fn.signature}**\n\n${fn.documentation}` },
|
||||||
|
detail: fn.signature,
|
||||||
|
range
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Add secrets namespace
|
||||||
|
suggestions.push({
|
||||||
|
label: 'Secrets',
|
||||||
|
kind: monaco.languages.CompletionItemKind.Module,
|
||||||
|
insertText: 'Secrets',
|
||||||
|
documentation: { value: 'Access your configured secrets' },
|
||||||
|
detail: 'namespace',
|
||||||
|
range
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner.Secrets. completions (dynamic based on user's secrets)
|
||||||
|
if (textBefore.endsWith('Runner.Secrets.')) {
|
||||||
|
secretKeys.forEach(key => {
|
||||||
|
// Convert to PascalCase for Go/C#
|
||||||
|
const secretName = key.split('_').map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join('')
|
||||||
|
suggestions.push({
|
||||||
|
label: secretName,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Constant,
|
||||||
|
insertText: secretName,
|
||||||
|
documentation: { value: `Secret: \`${key}\`` },
|
||||||
|
detail: 'string',
|
||||||
|
range
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// event. completions (based on current hook)
|
||||||
|
if (textBefore.endsWith('event.')) {
|
||||||
|
const eventType = sdkSchema.events?.[hook]
|
||||||
|
if (eventType) {
|
||||||
|
eventType.fields.forEach(field => {
|
||||||
|
suggestions.push({
|
||||||
|
label: field.name,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Field,
|
||||||
|
insertText: field.name,
|
||||||
|
documentation: { value: field.doc || `${field.type}` },
|
||||||
|
detail: field.type,
|
||||||
|
range
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested type completions (e.g., event.Post.)
|
||||||
|
// Match patterns like: event.Post. or event.Author.
|
||||||
|
const nestedMatch = textBefore.match(/event\.(\w+)\.$/)
|
||||||
|
if (nestedMatch) {
|
||||||
|
const parentField = nestedMatch[1]
|
||||||
|
// Find the type of this field from the event
|
||||||
|
const eventType = sdkSchema.events?.[hook]
|
||||||
|
const field = eventType?.fields.find(f => f.name === parentField)
|
||||||
|
if (field) {
|
||||||
|
// Get nested type fields
|
||||||
|
const nestedFields = getFieldsForType(field.type)
|
||||||
|
nestedFields.forEach(nestedField => {
|
||||||
|
suggestions.push({
|
||||||
|
label: nestedField.name,
|
||||||
|
kind: monaco.languages.CompletionItemKind.Field,
|
||||||
|
insertText: nestedField.name,
|
||||||
|
documentation: { value: nestedField.doc || `${nestedField.type}` },
|
||||||
|
detail: nestedField.type,
|
||||||
|
range
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { suggestions }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
disposablesRef.current.push(completionProvider)
|
||||||
|
|
||||||
|
// Hover provider
|
||||||
|
const hoverProvider = monaco.languages.registerHoverProvider(langId, {
|
||||||
|
provideHover: (model: editor.ITextModel, position: Position) => {
|
||||||
|
const word = model.getWordAtPosition(position)
|
||||||
|
if (!word) return null
|
||||||
|
|
||||||
|
// Check Runner functions
|
||||||
|
const runnerFn = sdkSchema.Runner.functions?.find(f => f.name === word.word)
|
||||||
|
if (runnerFn) {
|
||||||
|
return {
|
||||||
|
contents: [
|
||||||
|
{ value: `**Runner.${runnerFn.name}**` },
|
||||||
|
{ value: `\`\`\`\n${runnerFn.signature}\n\`\`\`` },
|
||||||
|
{ value: runnerFn.documentation }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
disposablesRef.current.push(hoverProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposablesRef.current.forEach(d => d.dispose())
|
||||||
|
disposablesRef.current = []
|
||||||
|
}
|
||||||
|
}, [language, sdkSchema, secretKeys, hook, getFieldsForType])
|
||||||
|
|
||||||
|
// Configure Monaco before editor mounts
|
||||||
|
const handleBeforeMount = useCallback((monaco: Monaco) => {
|
||||||
|
// Define theme for all languages
|
||||||
|
defineWriteKitTheme(monaco)
|
||||||
|
|
||||||
|
// TypeScript-specific configuration
|
||||||
|
if (language === 'typescript') {
|
||||||
|
// Configure TypeScript compiler options
|
||||||
|
// Don't set 'lib' explicitly - let Monaco use defaults which include all ES libs
|
||||||
|
// This ensures JSON, Array, Object, Math, etc. are available
|
||||||
|
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||||
|
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||||||
|
module: monaco.languages.typescript.ModuleKind.ESNext,
|
||||||
|
strict: true,
|
||||||
|
noEmit: true,
|
||||||
|
esModuleInterop: true,
|
||||||
|
skipLibCheck: true,
|
||||||
|
allowNonTsExtensions: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enable full diagnostics for hover info and error checking
|
||||||
|
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||||
|
noSemanticValidation: false,
|
||||||
|
noSyntaxValidation: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Eager model sync ensures types are available immediately
|
||||||
|
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true)
|
||||||
|
|
||||||
|
// Add WriteKit SDK types
|
||||||
|
const sdk = getTypeScriptSDK(secretKeys)
|
||||||
|
monaco.languages.typescript.typescriptDefaults.addExtraLib(
|
||||||
|
sdk,
|
||||||
|
'file:///node_modules/@writekit/sdk/index.d.ts'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [language, secretKeys])
|
||||||
|
|
||||||
|
const handleMount = useCallback((editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||||
|
editorRef.current = editor
|
||||||
|
monacoRef.current = monaco
|
||||||
|
// Theme is already defined in beforeMount, just apply it
|
||||||
|
monaco.editor.setTheme('writekit-dark')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isFullHeight = height === '100%'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`overflow-hidden ${isFullHeight ? 'h-full' : 'border border-border'}`}>
|
||||||
|
<Editor
|
||||||
|
height={height}
|
||||||
|
language={LANGUAGE_MAP[language]}
|
||||||
|
value={value}
|
||||||
|
onChange={v => onChange(v || '')}
|
||||||
|
beforeMount={handleBeforeMount}
|
||||||
|
onMount={handleMount}
|
||||||
|
theme="vs-dark"
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: '"JetBrains Mono", "SF Mono", Consolas, monospace',
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
tabSize: language === 'go' ? 4 : 2,
|
||||||
|
padding: { top: 16, bottom: 16 },
|
||||||
|
renderLineHighlight: 'line',
|
||||||
|
renderLineHighlightOnlyWhenFocus: true,
|
||||||
|
overviewRulerLanes: 0,
|
||||||
|
hideCursorInOverviewRuler: true,
|
||||||
|
cursorBlinking: 'blink',
|
||||||
|
cursorSmoothCaretAnimation: 'off',
|
||||||
|
smoothScrolling: true,
|
||||||
|
suggestOnTriggerCharacters: true,
|
||||||
|
scrollbar: {
|
||||||
|
vertical: 'auto',
|
||||||
|
horizontal: 'auto',
|
||||||
|
verticalScrollbarSize: 10,
|
||||||
|
horizontalScrollbarSize: 10,
|
||||||
|
useShadows: false,
|
||||||
|
},
|
||||||
|
bracketPairColorization: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
354
studio/src/components/editor/PostEditor.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react'
|
||||||
|
import { BubbleMenu } from '@tiptap/react/menus'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Link from '@tiptap/extension-link'
|
||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
|
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
|
||||||
|
import TaskList from '@tiptap/extension-task-list'
|
||||||
|
import TaskItem from '@tiptap/extension-task-item'
|
||||||
|
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||||
|
import { Markdown } from '@tiptap/markdown'
|
||||||
|
import { common, createLowlight } from 'lowlight'
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $editorPost, broadcastPreview } from '../../stores/editor'
|
||||||
|
import { $settings } from '../../stores/settings'
|
||||||
|
import { Icons } from '../shared/Icons'
|
||||||
|
import { SlashCommands } from './SlashCommands'
|
||||||
|
|
||||||
|
const lowlight = createLowlight(common)
|
||||||
|
|
||||||
|
interface PostEditorProps {
|
||||||
|
onChange?: (markdown: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(file: File): Promise<string | null> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/studio/assets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) return null
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return data.url
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostEditor({ onChange }: PostEditorProps) {
|
||||||
|
const post = useStore($editorPost)
|
||||||
|
const settings = useStore($settings)
|
||||||
|
const isInitialMount = useRef(true)
|
||||||
|
const skipNextUpdate = useRef(false)
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [linkUrl, setLinkUrl] = useState('')
|
||||||
|
const [showLinkInput, setShowLinkInput] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
codeBlock: false,
|
||||||
|
link: false,
|
||||||
|
}),
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
autolink: true,
|
||||||
|
}),
|
||||||
|
Image.configure({
|
||||||
|
allowBase64: true,
|
||||||
|
inline: false,
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: 'Start writing...',
|
||||||
|
}),
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableHeader,
|
||||||
|
TaskList,
|
||||||
|
TaskItem.configure({
|
||||||
|
nested: true,
|
||||||
|
}),
|
||||||
|
CodeBlockLowlight.configure({
|
||||||
|
lowlight,
|
||||||
|
}),
|
||||||
|
Markdown,
|
||||||
|
SlashCommands,
|
||||||
|
],
|
||||||
|
content: post.content || '',
|
||||||
|
contentType: 'markdown',
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose max-w-none focus:outline-none min-h-[50vh] p-6',
|
||||||
|
style: 'font-size: 1.0625rem; line-height: 1.7;',
|
||||||
|
},
|
||||||
|
handleDrop: (_view, event, _slice, moved) => {
|
||||||
|
if (moved || !event.dataTransfer?.files.length) return false
|
||||||
|
|
||||||
|
const file = event.dataTransfer.files[0]
|
||||||
|
if (!file.type.startsWith('image/')) return false
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
handleImageUpload(file)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
handlePaste: (_view, event) => {
|
||||||
|
const items = event.clipboardData?.items
|
||||||
|
if (!items) return false
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
event.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) handleImageUpload(file)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
if (skipNextUpdate.current) {
|
||||||
|
skipNextUpdate.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const markdown = editor.getMarkdown()
|
||||||
|
onChange?.(markdown)
|
||||||
|
broadcastPreview(markdown)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleImageUpload = useCallback(async (file: File) => {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
const url = await uploadImage(file)
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setImage({ src: url }).run()
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
if (e.currentTarget === e.target || !wrapperRef.current?.contains(e.relatedTarget as Node)) {
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleWrapperClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (e.target === wrapperRef.current && editor) {
|
||||||
|
editor.chain().focus().run()
|
||||||
|
}
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
const setLink = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
if (linkUrl === '') {
|
||||||
|
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
|
} else {
|
||||||
|
const url = linkUrl.startsWith('http') ? linkUrl : `https://${linkUrl}`
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||||
|
}
|
||||||
|
setShowLinkInput(false)
|
||||||
|
setLinkUrl('')
|
||||||
|
}, [editor, linkUrl])
|
||||||
|
|
||||||
|
const openLinkInput = useCallback(() => {
|
||||||
|
if (!editor) return
|
||||||
|
const previousUrl = editor.getAttributes('link').href || ''
|
||||||
|
setLinkUrl(previousUrl)
|
||||||
|
setShowLinkInput(true)
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentContent = editor.getMarkdown()
|
||||||
|
if (currentContent !== post.content) {
|
||||||
|
skipNextUpdate.current = true
|
||||||
|
editor.commands.setContent(post.content || '', { contentType: 'markdown' })
|
||||||
|
}
|
||||||
|
}, [editor, post.content])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const codeTheme = settings.code_theme || 'github'
|
||||||
|
const id = 'code-theme-css'
|
||||||
|
let link = document.getElementById(id) as HTMLLinkElement | null
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link')
|
||||||
|
link.id = id
|
||||||
|
link.rel = 'stylesheet'
|
||||||
|
document.head.appendChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
link.href = `/api/studio/code-theme.css?theme=${codeTheme}`
|
||||||
|
}, [settings.code_theme])
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={`editor-wrapper h-full overflow-auto cursor-text relative ${isDragging ? 'bg-accent/5' : ''}`}
|
||||||
|
onClick={handleWrapperClick}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{isDragging && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||||
|
<div className="px-4 py-2 bg-accent text-white text-sm font-medium rounded">
|
||||||
|
Drop image to upload
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
className="flex items-center gap-0.5 p-1 bg-surface border border-border shadow-lg rounded"
|
||||||
|
>
|
||||||
|
{showLinkInput ? (
|
||||||
|
<div className="flex items-center gap-1 px-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={linkUrl}
|
||||||
|
onChange={(e) => setLinkUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
setLink()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setShowLinkInput(false)
|
||||||
|
setLinkUrl('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Enter URL..."
|
||||||
|
className="w-48 px-2 py-1 text-xs bg-bg border border-border rounded focus:outline-none focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={setLink}
|
||||||
|
className="p-1.5 text-accent hover:bg-accent/10 rounded"
|
||||||
|
>
|
||||||
|
<Icons.Check className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowLinkInput(false)
|
||||||
|
setLinkUrl('')
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-muted hover:bg-bg rounded"
|
||||||
|
>
|
||||||
|
<Icons.Close className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
isActive={editor.isActive('bold')}
|
||||||
|
title="Bold (Ctrl+B)"
|
||||||
|
>
|
||||||
|
<Icons.Bold className="w-3.5 h-3.5" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
isActive={editor.isActive('italic')}
|
||||||
|
title="Italic (Ctrl+I)"
|
||||||
|
>
|
||||||
|
<Icons.Italic className="w-3.5 h-3.5" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
isActive={editor.isActive('strike')}
|
||||||
|
title="Strikethrough"
|
||||||
|
>
|
||||||
|
<Icons.Strikethrough className="w-3.5 h-3.5" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
isActive={editor.isActive('code')}
|
||||||
|
title="Inline Code (Ctrl+E)"
|
||||||
|
>
|
||||||
|
<Icons.CodeInline className="w-3.5 h-3.5" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={openLinkInput}
|
||||||
|
isActive={editor.isActive('link')}
|
||||||
|
title="Add Link (Ctrl+K)"
|
||||||
|
>
|
||||||
|
<Icons.Link className="w-3.5 h-3.5" />
|
||||||
|
</ToolbarButton>
|
||||||
|
{editor.isActive('link') && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().unsetLink().run()}
|
||||||
|
title="Remove Link"
|
||||||
|
>
|
||||||
|
<Icons.LinkOff className="w-3.5 h-3.5" />
|
||||||
|
</ToolbarButton>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</BubbleMenu>
|
||||||
|
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarButton({
|
||||||
|
onClick,
|
||||||
|
isActive,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void
|
||||||
|
isActive?: boolean
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
className={`p-1.5 rounded transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent/10 text-accent'
|
||||||
|
: 'text-muted hover:text-text hover:bg-bg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
270
studio/src/components/editor/SlashCommands.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { ReactRenderer } from '@tiptap/react'
|
||||||
|
import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion'
|
||||||
|
import tippy, { type Instance as TippyInstance } from 'tippy.js'
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
} from 'react'
|
||||||
|
import { Icons } from '../shared/Icons'
|
||||||
|
|
||||||
|
interface CommandItem {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
command: (props: { editor: any; range: any }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands: CommandItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Heading 1',
|
||||||
|
description: 'Large section heading',
|
||||||
|
icon: <Icons.Heading1 className="w-4 h-4" />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Heading 2',
|
||||||
|
description: 'Medium section heading',
|
||||||
|
icon: <Icons.Heading2 className="w-4 h-4" />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Heading 3',
|
||||||
|
description: 'Small section heading',
|
||||||
|
icon: <Icons.Heading3 className="w-4 h-4" />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Bullet List',
|
||||||
|
description: 'Create a bullet list',
|
||||||
|
icon: <Icons.List className="w-4 h-4" />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleBulletList().run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Numbered List',
|
||||||
|
description: 'Create a numbered list',
|
||||||
|
icon: <Icons.ListOrdered className="w-4 h-4" />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleOrderedList().run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Task List',
|
||||||
|
description: 'Create a task list with checkboxes',
|
||||||
|
icon: <Icons.Check className="w-4 h-4" />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleTaskList().run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Code Block',
|
||||||
|
description: 'Insert a code block',
|
||||||
|
icon: <Icons.Code className="w-4 h-4" />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Blockquote',
|
||||||
|
description: 'Insert a quote',
|
||||||
|
icon: <Icons.Quote className="w-4 h-4" />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleBlockquote().run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Horizontal Rule',
|
||||||
|
description: 'Insert a divider',
|
||||||
|
icon: <span className="w-4 h-4 flex items-center justify-center">—</span>,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Image',
|
||||||
|
description: 'Insert an image from URL',
|
||||||
|
icon: <Icons.Image className="w-4 h-4" />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
const url = window.prompt('Enter image URL:')
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().deleteRange(range).setImage({ src: url }).run()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface CommandListProps {
|
||||||
|
items: CommandItem[]
|
||||||
|
command: (item: CommandItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandListRef {
|
||||||
|
onKeyDown: (props: { event: KeyboardEvent }) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandList = forwardRef<CommandListRef, CommandListProps>(
|
||||||
|
({ items, command }, ref) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const item = items[index]
|
||||||
|
if (item) {
|
||||||
|
command(item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[items, command]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
onKeyDown: ({ event }) => {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
setSelectedIndex((prev) => (prev + items.length - 1) % items.length)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % items.length)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
selectItem(selectedIndex)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface border border-border shadow-lg rounded p-2 text-sm text-muted">
|
||||||
|
No results
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface border border-border shadow-lg rounded py-1 min-w-[200px] max-h-[300px] overflow-y-auto">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item.title}
|
||||||
|
onClick={() => selectItem(index)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
index === selectedIndex
|
||||||
|
? 'bg-accent/10 text-text'
|
||||||
|
: 'text-muted hover:bg-bg hover:text-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0 text-muted">{item.icon}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-text">{item.title}</div>
|
||||||
|
<div className="text-xs text-muted truncate">{item.description}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
CommandList.displayName = 'CommandList'
|
||||||
|
|
||||||
|
const suggestion: Omit<SuggestionOptions<CommandItem>, 'editor'> = {
|
||||||
|
items: ({ query }) => {
|
||||||
|
return commands.filter((item) =>
|
||||||
|
item.title.toLowerCase().includes(query.toLowerCase())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
render: () => {
|
||||||
|
let component: ReactRenderer<CommandListRef> | null = null
|
||||||
|
let popup: TippyInstance[] | null = null
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props) => {
|
||||||
|
component = new ReactRenderer(CommandList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!props.clientRect) return
|
||||||
|
|
||||||
|
popup = tippy('body', {
|
||||||
|
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: 'manual',
|
||||||
|
placement: 'bottom-start',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate: (props) => {
|
||||||
|
component?.updateProps(props)
|
||||||
|
|
||||||
|
if (!props.clientRect) return
|
||||||
|
|
||||||
|
popup?.[0]?.setProps({
|
||||||
|
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown: (props) => {
|
||||||
|
if (props.event.key === 'Escape') {
|
||||||
|
popup?.[0]?.hide()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return component?.ref?.onKeyDown(props) ?? false
|
||||||
|
},
|
||||||
|
|
||||||
|
onExit: () => {
|
||||||
|
popup?.[0]?.destroy()
|
||||||
|
component?.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SlashCommands = Extension.create({
|
||||||
|
name: 'slashCommands',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
suggestion: {
|
||||||
|
char: '/',
|
||||||
|
command: ({ editor, range, props }: { editor: any; range: any; props: CommandItem }) => {
|
||||||
|
props.command({ editor, range })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...suggestion,
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
105
studio/src/components/editor/SourceEditor.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
import Editor, { type Monaco } from '@monaco-editor/react'
|
||||||
|
import type { editor } from 'monaco-editor'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $editorPost } from '../../stores/editor'
|
||||||
|
|
||||||
|
interface SourceEditorProps {
|
||||||
|
onChange?: (markdown: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const defineWriteKitTheme = (monaco: Monaco) => {
|
||||||
|
monaco.editor.defineTheme('writekit-dark', {
|
||||||
|
base: 'vs-dark',
|
||||||
|
inherit: false,
|
||||||
|
rules: [
|
||||||
|
{ token: '', foreground: 'c4c4c4' },
|
||||||
|
{ token: 'comment', foreground: '525252', fontStyle: 'italic' },
|
||||||
|
{ token: 'keyword', foreground: 'd4d4d4' },
|
||||||
|
{ token: 'string', foreground: '34d399' },
|
||||||
|
{ token: 'number', foreground: 'fbbf24' },
|
||||||
|
{ token: 'type', foreground: '22d3ee' },
|
||||||
|
{ token: 'function', foreground: 'f5f5f5' },
|
||||||
|
{ token: 'variable', foreground: 'c4c4c4' },
|
||||||
|
{ token: 'operator', foreground: '737373' },
|
||||||
|
{ token: 'delimiter', foreground: '525252' },
|
||||||
|
{ token: 'tag', foreground: '22d3ee' },
|
||||||
|
{ token: 'attribute.name', foreground: 'a3a3a3' },
|
||||||
|
{ token: 'attribute.value', foreground: '34d399' },
|
||||||
|
{ token: 'string.link', foreground: '34d399', fontStyle: 'underline' },
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#1c1c1e',
|
||||||
|
'editor.foreground': '#c4c4c4',
|
||||||
|
'editor.lineHighlightBackground': '#232326',
|
||||||
|
'editor.lineHighlightBorder': '#00000000',
|
||||||
|
'editor.selectionBackground': '#10b98135',
|
||||||
|
'editor.inactiveSelectionBackground': '#10b98118',
|
||||||
|
'editorCursor.foreground': '#10b981',
|
||||||
|
'editorLineNumber.foreground': '#3f3f46',
|
||||||
|
'editorLineNumber.activeForeground': '#71717a',
|
||||||
|
'editorIndentGuide.background1': '#27272a',
|
||||||
|
'editorIndentGuide.activeBackground1': '#3f3f46',
|
||||||
|
'scrollbarSlider.background': '#3f3f4660',
|
||||||
|
'scrollbarSlider.hoverBackground': '#52525b80',
|
||||||
|
'editorWidget.background': '#1e1e21',
|
||||||
|
'editorWidget.border': '#3f3f46',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceEditor({ onChange }: SourceEditorProps) {
|
||||||
|
const post = useStore($editorPost)
|
||||||
|
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
|
||||||
|
|
||||||
|
const handleBeforeMount = useCallback((monaco: Monaco) => {
|
||||||
|
defineWriteKitTheme(monaco)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMount = useCallback((editor: editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||||
|
editorRef.current = editor
|
||||||
|
monaco.editor.setTheme('writekit-dark')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleChange = useCallback((value: string | undefined) => {
|
||||||
|
onChange?.(value || '')
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-hidden">
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language="markdown"
|
||||||
|
value={post.content}
|
||||||
|
onChange={handleChange}
|
||||||
|
beforeMount={handleBeforeMount}
|
||||||
|
onMount={handleMount}
|
||||||
|
theme="vs-dark"
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: '"JetBrains Mono", "SF Mono", Consolas, monospace',
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
tabSize: 2,
|
||||||
|
padding: { top: 16, bottom: 16 },
|
||||||
|
renderLineHighlight: 'line',
|
||||||
|
renderLineHighlightOnlyWhenFocus: true,
|
||||||
|
wordWrap: 'on',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
overviewRulerLanes: 0,
|
||||||
|
hideCursorInOverviewRuler: true,
|
||||||
|
cursorBlinking: 'blink',
|
||||||
|
smoothScrolling: true,
|
||||||
|
scrollbar: {
|
||||||
|
vertical: 'auto',
|
||||||
|
horizontal: 'hidden',
|
||||||
|
verticalScrollbarSize: 10,
|
||||||
|
useShadows: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
4
studio/src/components/editor/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { PluginEditor } from './PluginEditor'
|
||||||
|
export { PostEditor } from './PostEditor'
|
||||||
|
export { SourceEditor } from './SourceEditor'
|
||||||
|
export { MetadataPanel } from './MetadataPanel'
|
||||||
76
studio/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $router } from '../../stores/router'
|
||||||
|
import { Icons, type IconComponent } from '../shared/Icons'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
route: string
|
||||||
|
label: string
|
||||||
|
Icon: IconComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ route: 'posts', label: 'Posts', Icon: Icons.Posts },
|
||||||
|
{ route: 'analytics', label: 'Analytics', Icon: Icons.Analytics },
|
||||||
|
{ route: 'general', label: 'General', Icon: Icons.Settings },
|
||||||
|
{ route: 'design', label: 'Design', Icon: Icons.Design },
|
||||||
|
{ route: 'domain', label: 'Domain', Icon: Icons.Domain },
|
||||||
|
{ route: 'engagement', label: 'Engagement', Icon: Icons.Engagement },
|
||||||
|
{ route: 'monetization', label: 'Monetization', Icon: Icons.Monetization },
|
||||||
|
{ route: 'api', label: 'API Keys', Icon: Icons.ApiKeys },
|
||||||
|
{ route: 'data', label: 'Data', Icon: Icons.Data },
|
||||||
|
{ route: 'billing', label: 'Billing', Icon: Icons.Billing },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ className = '' }: HeaderProps) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const page = useStore($router)
|
||||||
|
const currentRoute = page?.route ?? 'posts'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={`lg:hidden bg-surface border-b border-border sticky top-0 z-50 ${className}`}>
|
||||||
|
<div className="h-14 flex items-center justify-between px-4">
|
||||||
|
<a href="/" className="block">
|
||||||
|
<div className="text-[15px] font-bold tracking-tight text-text">WriteKit</div>
|
||||||
|
<div className="text-[11px] font-medium text-muted tracking-wide">Studio</div>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
className="w-9 h-9 flex items-center justify-center hover:bg-border transition-colors"
|
||||||
|
>
|
||||||
|
{menuOpen ? <Icons.Close className="text-lg" /> : <Icons.Menu className="text-lg" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<nav className="p-2 border-t border-border bg-surface">
|
||||||
|
{navItems.map(item => (
|
||||||
|
<a
|
||||||
|
key={item.route}
|
||||||
|
href={`/studio/${item.route}`}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className={currentRoute === item.route ? 'nav-item-active' : 'nav-item'}
|
||||||
|
>
|
||||||
|
<item.Icon className={`text-sm ${currentRoute === item.route ? 'text-accent opacity-100' : 'opacity-50'}`} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<div className="border-t border-border mt-2 pt-2">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
target="_blank"
|
||||||
|
className="nav-item"
|
||||||
|
>
|
||||||
|
<Icons.ExternalLink className="text-sm opacity-50" />
|
||||||
|
<span>View Site</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
studio/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $router } from '../../stores/router'
|
||||||
|
import { Icons, type IconComponent } from '../shared/Icons'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
route: string
|
||||||
|
label: string
|
||||||
|
Icon: IconComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavSection {
|
||||||
|
title: string
|
||||||
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigation: NavSection[] = [
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
items: [
|
||||||
|
{ route: 'home', label: 'Home', Icon: Icons.Home },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Content',
|
||||||
|
items: [
|
||||||
|
{ route: 'posts', label: 'Posts', Icon: Icons.Posts },
|
||||||
|
{ route: 'analytics', label: 'Analytics', Icon: Icons.Analytics },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Site',
|
||||||
|
items: [
|
||||||
|
{ route: 'general', label: 'General', Icon: Icons.Settings },
|
||||||
|
{ route: 'design', label: 'Design', Icon: Icons.Design },
|
||||||
|
{ route: 'domain', label: 'Domain', Icon: Icons.Domain },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Readers',
|
||||||
|
items: [
|
||||||
|
{ route: 'engagement', label: 'Engagement', Icon: Icons.Engagement },
|
||||||
|
{ route: 'monetization', label: 'Monetization', Icon: Icons.Monetization },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Developer',
|
||||||
|
items: [
|
||||||
|
{ route: 'plugins', label: 'Plugins', Icon: Icons.Code },
|
||||||
|
{ route: 'api', label: 'API Keys', Icon: Icons.ApiKeys },
|
||||||
|
{ route: 'data', label: 'Data', Icon: Icons.Data },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Account',
|
||||||
|
items: [
|
||||||
|
{ route: 'billing', label: 'Billing', Icon: Icons.Billing },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const page = useStore($router)
|
||||||
|
const currentRoute = page?.route ?? 'posts'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-56 h-screen bg-bg border-r border-border flex flex-col">
|
||||||
|
<div className="px-4 py-6">
|
||||||
|
<a href="/" className="block group">
|
||||||
|
<div className="text-[15px] font-bold tracking-tight text-text">
|
||||||
|
WriteKit
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-medium text-muted tracking-wide">
|
||||||
|
Studio
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto px-3">
|
||||||
|
{navigation.map((section, idx) => (
|
||||||
|
<div key={section.title || idx} className="mb-1">
|
||||||
|
{section.title && <div className="nav-section">{section.title}</div>}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{section.items.map(item => {
|
||||||
|
const href = item.route === 'home' ? '/studio' : `/studio/${item.route}`
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={item.route}
|
||||||
|
href={href}
|
||||||
|
className={currentRoute === item.route ? 'nav-item-active' : 'nav-item'}
|
||||||
|
>
|
||||||
|
<item.Icon className={`text-sm ${currentRoute === item.route ? 'text-accent opacity-100' : 'opacity-50'}`} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
studio/src/components/layout/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { Sidebar } from './Sidebar'
|
||||||
|
export { Header } from './Header'
|
||||||