diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..8ea6bbb --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,14 @@ +# Copy to `.env.prod` on the deploy host (CT502) and fill with real secrets. +# `.env.prod` is gitignored — never commit real credentials. + +# Database +POSTGRES_DB=xip +POSTGRES_USER=xip +POSTGRES_PASSWORD=change-me-to-a-strong-secret + +# Public origin (baked into the frontend build + used by the WS URL) +PUBLIC_URL=https://xip.kerboul.me + +# Paywall: "true" = open bar (everything free for everyone), "false" = paywall on +# (free only on localhost, per the README). +XIP_OPEN_BAR=true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..23ef490 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell scripts must keep LF endings or they break with "bad interpreter" on Linux. +*.sh text eol=lf +docker-entrypoint.sh text eol=lf diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..04aca1b --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,26 @@ +name: Deploy XIP + +# Auto-deploy on every push to main. The runner SSHes into the xip-app CT +# (Echelon CT502) and runs scripts/deploy.sh, which pulls + rebuilds the stack. +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Set up SSH + run: | + command -v ssh >/dev/null 2>&1 || (apt-get update && apt-get install -y --no-install-recommends openssh-client) + mkdir -p ~/.ssh + printf '%s\n' "${{ secrets.XIP_DEPLOY_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "${{ secrets.XIP_DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true + + - name: Deploy over SSH + run: | + ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \ + "${{ secrets.XIP_DEPLOY_USER }}@${{ secrets.XIP_DEPLOY_HOST }}" \ + 'bash /opt/xip/scripts/deploy.sh' diff --git a/.gitignore b/.gitignore index c9d16cb..5dc5fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ .env .env.local +.env.prod *.log .DS_Store Thumbs.db diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..65433eb --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,64 @@ +# Déploiement XIP + +Production : **https://xip.kerboul.me** — déploiement continu sur push `main`. + +## Architecture (pattern Vireli, cluster SENTINEL) + +``` +Cloudflare (*.kerboul.me) ─► VPS WireGuard ─► Traefik (CT102, Cerberus) + │ Host(`xip.kerboul.me`) → http://192.168.1.242:80 + ▼ + CT502 « xip-app » (Echelon, Docker host) + ┌───────────────────────────────────────┐ + │ web (nginx:80) │ + │ ├── / → SPA Vue (statique) │ + │ ├── /api/ → backend:3000 │ + │ └── /ws → backend:3000 (WS) │ + │ backend (bun:3000, Hono + Prisma) │ + │ postgres:16 redis:7 │ + └───────────────────────────────────────┘ +``` + +Origine unique : le front (buildé avec `VITE_API_URL=https://xip.kerboul.me`) +appelle `/api` et `wss://xip.kerboul.me/ws`, nginx proxifie vers le backend. +Traefik termine le TLS (Let's Encrypt, DNS challenge Cloudflare). + +## CI/CD (Gitea Actions) + +`.gitea/workflows/deploy.yml` se déclenche sur push `main` (+ `workflow_dispatch`). +Le runner (CT121) se connecte en SSH au CT502 et exécute `scripts/deploy.sh` +(`git reset --hard origin/main` + `docker compose up -d --build`). + +Migrations Prisma + seed (idempotent) tournent au démarrage du conteneur backend +(`backend/docker-entrypoint.sh`). + +### Secrets du repo (Gitea → Settings → Actions → Secrets) +| Secret | Rôle | +|--------|------| +| `XIP_DEPLOY_HOST` | IP du CT502 (192.168.1.242) | +| `XIP_DEPLOY_USER` | utilisateur de déploiement (`deploy`) | +| `XIP_DEPLOY_KEY` | clé privée SSH autorisée sur le CT502 | + +## Fichiers + +| Fichier | Rôle | +|---------|------| +| `docker-compose.prod.yml` | stack prod (postgres, redis, backend, web) | +| `backend/Dockerfile` + `docker-entrypoint.sh` | image backend, migrate+seed au boot | +| `frontend/Dockerfile` | build Vite → nginx | +| `deploy/nginx.conf` | reverse proxy single-origin | +| `scripts/deploy.sh` | script de (re)déploiement sur le CT | +| `.env.prod` (non commité) | secrets : voir `.env.prod.example` | + +## Paywall + +`XIP_OPEN_BAR=true` (dans `.env.prod`) = **open bar** : toutes les fonctionnalités +payantes gratuites pour tout le monde. Mettre `false` pour réactiver le paywall +(gratuit uniquement en localhost). Logique centralisée dans `backend/src/lib/ip.ts` +(`isFree()`). + +## Redéploiement manuel + +```bash +ssh deploy@192.168.1.242 'bash /opt/xip/scripts/deploy.sh' +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..369cad0 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,22 @@ +# XIP backend — Bun + Hono + Prisma. +# Build context is the repo ROOT (see docker-compose.prod.yml) so we can copy backend/. +FROM oven/bun:1-debian AS deps +WORKDIR /app +COPY backend/package.json ./ +RUN bun install + +FROM oven/bun:1-debian AS runtime +WORKDIR /app +ENV NODE_ENV=production +# Prisma's query engine needs openssl + CA certs (generate downloads it over HTTPS). +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=deps /app/node_modules ./node_modules +COPY backend/ ./ +# Generate the Prisma client from the schema (no DB connection required). +RUN bunx prisma generate \ + && chmod +x docker-entrypoint.sh +EXPOSE 3000 +# Entrypoint applies migrations + seeds (idempotent) then starts the server. +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..2fd78a3 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Startup sequence for the XIP backend container. +# Runs on every (re)start so new migrations land automatically on deploy. +set -e +cd /app + +echo "[xip] Applying database migrations (prisma migrate deploy)…" +bunx prisma migrate deploy + +echo "[xip] Seeding catalogue + ads (idempotent upserts)…" +bun run prisma/seed.ts || echo "[xip] seed step failed (non-fatal) — continuing" + +echo "[xip] Starting backend on :${PORT:-3000}…" +exec bun run src/index.ts diff --git a/backend/src/lib/catalog.ts b/backend/src/lib/catalog.ts index 4ff25f5..b52a0ba 100644 --- a/backend/src/lib/catalog.ts +++ b/backend/src/lib/catalog.ts @@ -1,6 +1,6 @@ import { prisma } from "./prisma"; import { spend, getWallet, InsufficientCreditsError } from "./wallet"; -import { isLocalhost } from "./ip"; +import { isFree } from "./ip"; import { invalidatePerks, getPerksForIp } from "./perks"; /** @@ -105,7 +105,7 @@ export async function purchase( const product = await getProduct(productId); if (!product || !product.active) throw new PurchaseError("Produit introuvable", 404); - const free = isLocalhost(ip); + const free = isFree(ip); const price = effectivePrice(product, options); // Resolve which entitlement kind(s) this grants + per-IP limit checks. diff --git a/backend/src/lib/ip.ts b/backend/src/lib/ip.ts index 05b1ac2..df43d95 100644 --- a/backend/src/lib/ip.ts +++ b/backend/src/lib/ip.ts @@ -36,3 +36,13 @@ export function isLocalhost(ip: string): boolean { ip.startsWith("127.") ); } + +/** + * Free mode: the paywall is OFF. True on localhost (README rule "si localhost: + * pas de paywall"), OR whenever the deployment sets XIP_OPEN_BAR=true — the prod + * "open bar" where every paid feature is free for everyone. Every paywall gate + * in the app routes through this single helper. + */ +export function isFree(ip: string): boolean { + return process.env.XIP_OPEN_BAR === "true" || isLocalhost(ip); +} diff --git a/backend/src/lib/wallet.ts b/backend/src/lib/wallet.ts index ffac90a..246d059 100644 --- a/backend/src/lib/wallet.ts +++ b/backend/src/lib/wallet.ts @@ -1,6 +1,6 @@ import { prisma } from "./prisma"; import { redis } from "./redis"; -import { isLocalhost } from "./ip"; +import { isFree } from "./ip"; /** * Wallet engine — fictional "crédits XIP", keyed on IP (no accounts). @@ -41,7 +41,7 @@ export async function ensureWallet(ip: string): Promise { } export async function getWallet(ip: string): Promise { - if (isLocalhost(ip)) return { ip, balance: INFINITE, freeMode: true }; + if (isFree(ip)) return { ip, balance: INFINITE, freeMode: true }; await ensureWallet(ip); const w = await prisma.wallet.findUnique({ where: { ip } }).catch(() => null); const balance = w?.balance ?? 0; @@ -51,7 +51,7 @@ export async function getWallet(ip: string): Promise { /** Free, instant, satirical top-up. No-op for localhost (already infinite). */ export async function topUp(ip: string, amount = TOPUP_AMOUNT): Promise { - if (isLocalhost(ip)) return { ip, balance: INFINITE, freeMode: true }; + if (isFree(ip)) return { ip, balance: INFINITE, freeMode: true }; await ensureWallet(ip); const w = await prisma.wallet.update({ where: { ip }, @@ -83,7 +83,7 @@ export async function spend( reason: string, meta?: Record ): Promise { - if (isLocalhost(ip)) return INFINITE; + if (isFree(ip)) return INFINITE; if (amount <= 0) { // Free item — still record the (zero) purchase for history, no balance change. const w = await getWallet(ip); diff --git a/backend/src/routes/alert.ts b/backend/src/routes/alert.ts index 35d8fd4..48eb30a 100644 --- a/backend/src/routes/alert.ts +++ b/backend/src/routes/alert.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { getClientIp, isLocalhost } from "../lib/ip"; +import { getClientIp, isFree } from "../lib/ip"; import { prisma } from "../lib/prisma"; import { redis } from "../lib/redis"; import { spend } from "../lib/wallet"; @@ -24,7 +24,7 @@ alert.post("/", async (c) => { } // Must own the audio-alert entitlement (localhost bypasses). - if (!isLocalhost(ip)) { + if (!isFree(ip)) { const owned = await prisma.entitlement.findFirst({ where: { ip, kind: "audio-alert", active: true }, }); diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index 887fd54..36c08e4 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { prisma } from "../lib/prisma"; -import { getClientIp, isLocalhost } from "../lib/ip"; +import { getClientIp, isFree } from "../lib/ip"; import { recordMessage } from "../lib/stats"; import { broadcastNewMessage } from "../realtime"; import { getPerksForIp, getPerksForIps } from "../lib/perks"; @@ -11,7 +11,7 @@ const RICH_MAX = 64 * 1024; // 64 KB cap on rich markup /** Does this IP own the entitlement needed for a rich tier? */ async function ownsRich(ip: string, mode: "htmlcss" | "js"): Promise { - if (isLocalhost(ip)) return true; + if (isFree(ip)) return true; const kind = mode === "js" ? "rich-js" : "rich-htmlcss"; const now = new Date(); const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } }); diff --git a/backend/src/routes/uploads.ts b/backend/src/routes/uploads.ts index b751d77..a4d6199 100644 --- a/backend/src/routes/uploads.ts +++ b/backend/src/routes/uploads.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { randomUUID } from "node:crypto"; import { prisma } from "../lib/prisma"; -import { getClientIp, isLocalhost } from "../lib/ip"; +import { getClientIp, isFree } from "../lib/ip"; import { storeFile, absolutePathFor } from "../lib/storage"; const uploads = new Hono(); @@ -10,7 +10,7 @@ const FREE_LIMIT = 1_000_000; // 1 Mo for the free tier (README) const ABSOLUTE_MAX = 50_000_000; // hard cap even for paid, to protect the dev box async function ownsNoFileLimit(ip: string): Promise { - if (isLocalhost(ip)) return true; + if (isFree(ip)) return true; const rows = await prisma.entitlement.findMany({ where: { ip, kind: "no-file-limit", active: true }, }); diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..be30c49 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,42 @@ +# Single-origin reverse proxy for XIP (Vireli pattern). +# nginx serves the built SPA and proxies API + WebSocket to the bun backend. +server { + listen 80; + server_name _; + + # Uploads: backend allows up to 50 MB (ABSOLUTE_MAX). Give headroom. + client_max_body_size 60m; + + # ── API (REST + uploads) ──────────────────────────────────────────────── + location /api/ { + proxy_pass http://backend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # ── WebSocket (live feed + realtime stats) ────────────────────────────── + location /ws { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 3600s; + } + + # ── Health passthrough ────────────────────────────────────────────────── + location = /health { + proxy_pass http://backend:3000; + } + + # ── Static SPA (Vue history fallback) ─────────────────────────────────── + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d672e2d --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,67 @@ +# Production stack for XIP — runs on the dedicated CT (xip-app, Echelon CT502). +# Postgres + Redis + bun backend + nginx (serves SPA, proxies /api and /ws). +# Secrets come from .env.prod (gitignored), loaded via `--env-file .env.prod`. +services: + postgres: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-xip} + POSTGRES_USER: ${POSTGRES_USER:-xip} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env.prod} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-xip}"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7 + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 20 + + backend: + build: + context: . + dockerfile: backend/Dockerfile + restart: unless-stopped + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-xip}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-xip} + REDIS_URL: redis://redis:6379 + PORT: "3000" + NODE_ENV: production + # Prod "open bar": paywall disabled for everyone (see backend/src/lib/ip.ts). + XIP_OPEN_BAR: ${XIP_OPEN_BAR:-true} + volumes: + - uploads_data:/app/uploads + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + web: + build: + context: . + dockerfile: frontend/Dockerfile + args: + VITE_API_URL: ${PUBLIC_URL:-https://xip.kerboul.me} + restart: unless-stopped + ports: + - "80:80" + depends_on: + - backend + +volumes: + postgres_data: + redis_data: + uploads_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..267ca98 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,17 @@ +# XIP frontend — Vue 3 + Vite, built to static assets and served by nginx. +# Build context is the repo ROOT (see docker-compose.prod.yml). +FROM oven/bun:1-debian AS build +WORKDIR /app +COPY frontend/package.json ./ +RUN bun install +COPY frontend/ ./ +# Baked at build time. Must be the public absolute origin so the WebSocket URL +# (derived as API_URL.replace(/^http/,'ws') + '/ws') becomes wss://xip.kerboul.me/ws. +ARG VITE_API_URL=https://xip.kerboul.me +ENV VITE_API_URL=$VITE_API_URL +RUN bun run build + +FROM nginx:1.27-alpine AS runtime +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..4e43426 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Pull the latest main and (re)build the XIP stack on the deploy host. +# Invoked over SSH by the Gitea Actions workflow on every push to main, +# and runnable by hand on the CT for manual redeploys. +set -euo pipefail + +APP_DIR="${XIP_APP_DIR:-/opt/xip}" +COMPOSE_FILE="docker-compose.prod.yml" +ENV_FILE=".env.prod" + +cd "$APP_DIR" + +echo "==> Fetching latest origin/main…" +git fetch --all --prune +git reset --hard origin/main + +echo "==> Building + starting the stack…" +docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build --remove-orphans + +echo "==> Pruning dangling images…" +docker image prune -f >/dev/null 2>&1 || true + +echo "==> Current state:" +docker compose -f "$COMPOSE_FILE" ps + +echo "==> Deploy complete."