Merge pull request 'feat(deploy): run pdraim as a Node container on Gordon' (#2) from feat/gordon-container into main
21 files · +774 -2471
A .dockerignore +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | node_modules | |
| 2 | + | .svelte-kit | |
| 3 | + | build | |
| 4 | + | .git | |
| 5 | + | .github | |
| 6 | + | .worktrees | |
| 7 | + | .env | |
| 8 | + | .env.* | |
| 9 | + | .env example | |
| 10 | + | *.md | |
| 11 | + | docs | |
| 12 | + | .claude | |
| 13 | + | .cursor | |
| 14 | + | .serena | |
| 15 | + | .wrangler | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | node_modules | |||
| 2 | + | .svelte-kit | |||
| 3 | + | build | |||
| 4 | + | .git | |||
| 5 | + | .github | |||
| 6 | + | .worktrees | |||
| 7 | + | .env | |||
| 8 | + | .env.* | |||
| 9 | + | .env example | |||
| 10 | + | *.md | |||
| 11 | + | docs | |||
| 12 | + | .claude | |||
| 13 | + | .cursor | |||
| 14 | + | .serena | |||
| 15 | + | .wrangler | |||
M .github/dependabot.yml +0 -8
| @@ -40,14 +40,6 @@ updates: | |||
| 40 | 40 | update-types: | |
| 41 | 41 | - 'minor' | |
| 42 | 42 | - 'patch' | |
| 43 | - | # Group Drizzle ORM related packages | |
| 44 | - | drizzle: | |
| 45 | - | patterns: | |
| 46 | - | - 'drizzle-*' | |
| 47 | - | - '@libsql/*' | |
| 48 | - | update-types: | |
| 49 | - | - 'minor' | |
| 50 | - | - 'patch' | |
| 51 | 43 | # Set PR limits to avoid overwhelming the team | |
| 52 | 44 | open-pull-requests-limit: 8 | |
| 53 | 45 | # Ignore packages that require manual updates or are pinned for specific reasons | |
| @@ -40,14 +40,6 @@ updates: | |||||
| 40 | update-types: | 40 | update-types: | ||
| 41 | - 'minor' | 41 | - 'minor' | ||
| 42 | - 'patch' | 42 | - 'patch' | ||
| 43 | - | # Group Drizzle ORM related packages | |||
| 44 | - | drizzle: | |||
| 45 | - | patterns: | |||
| 46 | - | - 'drizzle-*' | |||
| 47 | - | - '@libsql/*' | |||
| 48 | - | update-types: | |||
| 49 | - | - 'minor' | |||
| 50 | - | - 'patch' | |||
| 51 | # Set PR limits to avoid overwhelming the team | 43 | # Set PR limits to avoid overwhelming the team | ||
| 52 | open-pull-requests-limit: 8 | 44 | open-pull-requests-limit: 8 | ||
| 53 | # Ignore packages that require manual updates or are pinned for specific reasons | 45 | # Ignore packages that require manual updates or are pinned for specific reasons | ||
M .github/workflows/ci.yml +0 -2
| @@ -18,8 +18,6 @@ on: | |||
| 18 | 18 | ||
| 19 | 19 | env: | |
| 20 | 20 | # Dummy values for svelte-check type generation | |
| 21 | - | TURSO_DATABASE_URL: 'libsql://dummy.turso.io' | |
| 22 | - | TURSO_AUTH_TOKEN: 'dummy-token' | |
| 23 | 21 | TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA' | |
| 24 | 22 | PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA' | |
| 25 | 23 | PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001' | |
| @@ -18,8 +18,6 @@ on: | |||||
| 18 | 18 | ||||
| 19 | env: | 19 | env: | ||
| 20 | # Dummy values for svelte-check type generation | 20 | # Dummy values for svelte-check type generation | ||
| 21 | - | TURSO_DATABASE_URL: 'libsql://dummy.turso.io' | |||
| 22 | - | TURSO_AUTH_TOKEN: 'dummy-token' | |||
| 23 | TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA' | 21 | TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA' | ||
| 24 | PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA' | 22 | PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA' | ||
| 25 | PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001' | 23 | PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001' | ||
M .gitignore +1 -0
| @@ -1,4 +1,5 @@ | |||
| 1 | 1 | node_modules | |
| 2 | + | .worktrees | |
| 2 | 3 | ||
| 3 | 4 | # Output | |
| 4 | 5 | .output | |
| @@ -1,4 +1,5 @@ | |||||
| 1 | node_modules | 1 | node_modules | ||
| 2 | + | .worktrees | |||
| 2 | 3 | ||||
| 3 | # Output | 4 | # Output | ||
| 4 | .output | 5 | .output | ||
A Dockerfile +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | FROM node:22-alpine AS build | |
| 2 | + | WORKDIR /app | |
| 3 | + | ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 | |
| 4 | + | RUN corepack enable | |
| 5 | + | ||
| 6 | + | COPY package.json pnpm-lock.yaml ./ | |
| 7 | + | RUN pnpm fetch | |
| 8 | + | COPY . . | |
| 9 | + | RUN pnpm install --frozen-lockfile --offline && pnpm run build | |
| 10 | + | ||
| 11 | + | # Every dependency is bundled into build/, so the runtime image needs no node_modules | |
| 12 | + | FROM node:22-alpine | |
| 13 | + | WORKDIR /app | |
| 14 | + | ENV NODE_ENV=production \ | |
| 15 | + | HOST=0.0.0.0 \ | |
| 16 | + | PORT=3000 | |
| 17 | + | ||
| 18 | + | COPY --from=build /app/build ./build | |
| 19 | + | COPY package.json server.js ./ | |
| 20 | + | ||
| 21 | + | USER node | |
| 22 | + | EXPOSE 3000 | |
| 23 | + | HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ | |
| 24 | + | CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" | |
| 25 | + | CMD ["node", "server.js"] | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | FROM node:22-alpine AS build | |||
| 2 | + | WORKDIR /app | |||
| 3 | + | ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 | |||
| 4 | + | RUN corepack enable | |||
| 5 | + | ||||
| 6 | + | COPY package.json pnpm-lock.yaml ./ | |||
| 7 | + | RUN pnpm fetch | |||
| 8 | + | COPY . . | |||
| 9 | + | RUN pnpm install --frozen-lockfile --offline && pnpm run build | |||
| 10 | + | ||||
| 11 | + | # Every dependency is bundled into build/, so the runtime image needs no node_modules | |||
| 12 | + | FROM node:22-alpine | |||
| 13 | + | WORKDIR /app | |||
| 14 | + | ENV NODE_ENV=production \ | |||
| 15 | + | HOST=0.0.0.0 \ | |||
| 16 | + | PORT=3000 | |||
| 17 | + | ||||
| 18 | + | COPY --from=build /app/build ./build | |||
| 19 | + | COPY package.json server.js ./ | |||
| 20 | + | ||||
| 21 | + | USER node | |||
| 22 | + | EXPOSE 3000 | |||
| 23 | + | HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ | |||
| 24 | + | CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" | |||
| 25 | + | CMD ["node", "server.js"] | |||
M README.md +13 -37
| @@ -76,48 +76,24 @@ Vous pouvez visualiser et gérer vos données via le dashboard Convex : | |||
| 76 | 76 | npx convex dashboard | |
| 77 | 77 | ``` | |
| 78 | 78 | ||
| 79 | - | ## Déploiement Cloudflare Pages | |
| 79 | + | ## Déploiement | |
| 80 | 80 | ||
| 81 | - | Le projet est déployé sur [Cloudflare Pages](https://pages.cloudflare.com/). Les Workers Cloudflare ont des **limitations strictes** à respecter. | |
| 81 | + | Le site tourne dans une image conteneur Node ([`adapter-node`](https://svelte.dev/docs/kit/adapter-node)), écoutant sur le port `3000`. | |
| 82 | 82 | ||
| 83 | - | ### Opérations interdites au niveau global/module | |
| 84 | - | ||
| 85 | - | Cloudflare Workers **n'autorisent PAS** les opérations suivantes au niveau global (en dehors des handlers de requête) : | |
| 86 | - | ||
| 87 | - | ```typescript | |
| 88 | - | // INTERDIT - provoque "Disallowed operation called within global scope" | |
| 89 | - | setInterval(() => { ... }, 1000); | |
| 90 | - | setTimeout(() => { ... }, 1000); | |
| 91 | - | fetch('https://api.example.com'); | |
| 92 | - | crypto.getRandomValues(new Uint8Array(16)); | |
| 93 | - | ||
| 94 | - | // AUTORISE - à l'intérieur d'un handler | |
| 95 | - | export const GET: RequestHandler = async () => { | |
| 96 | - | const random = crypto.getRandomValues(new Uint8Array(16)); | |
| 97 | - | const response = await fetch('https://api.example.com'); | |
| 98 | - | // ... | |
| 99 | - | }; | |
| 83 | + | ```bash | |
| 84 | + | docker build -t pdraim . | |
| 85 | + | docker run --rm -p 3000:3000 --env-file .env.local -e ORIGIN=http://localhost:3000 pdraim | |
| 100 | 86 | ``` | |
| 101 | 87 | ||
| 102 | - | ### Bonnes pratiques | |
| 103 | - | ||
| 104 | - | | A eviter | Alternative | | |
| 105 | - | | ----------------------------------------------------- | ------------------------------------------------------ | | |
| 106 | - | | `setInterval` pour cleanup | Lazy cleanup à chaque requête | | |
| 107 | - | | `fetch()` au chargement du module | `fetch()` dans les handlers | | |
| 108 | - | | Création de clients API au niveau module | Création lazy ou dans les handlers | | |
| 109 | - | | `process.exit()` | Retourner une Response avec status d'erreur | | |
| 110 | - | | Bibliothèques avec worker threads (ex: `pino-pretty`) | Versions compatibles edge (ex: `pino` sans transports) | | |
| 111 | - | ||
| 112 | - | ### Variables d'environnement | |
| 113 | - | ||
| 114 | - | - Utilisez `$env/dynamic/private` (runtime) au lieu de `$env/static/private` (build-time) | |
| 115 | - | - Les variables définies dans le dashboard Cloudflare ne sont disponibles qu'au runtime | |
| 116 | - | ||
| 117 | - | ### Références | |
| 88 | + | Toutes les variables sont lues au runtime (`$env/dynamic/*`) : la même image sert en local et en production. | |
| 118 | 89 | ||
| 119 | - | - [Cloudflare Workers Runtime APIs](https://developers.cloudflare.com/workers/runtime-apis/) | |
| 120 | - | - [SvelteKit Cloudflare Adapter](https://svelte.dev/docs/kit/adapter-cloudflare) | |
| 90 | + | | Variable | Rôle | | |
| 91 | + | | --------------------------------------------------- | --------------------------------------- | | |
| 92 | + | | `PUBLIC_CONVEX_URL`, `CONVEX_API_SECRET` | Accès à Convex | | |
| 93 | + | | `PUBLIC_TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile | | |
| 94 | + | | `ORIGIN` | URL publique (ex. `https://pdraim.org`) | | |
| 95 | + | | `ADDRESS_HEADER` (ex. `x-real-ip`) | IP client derrière les reverse proxies | | |
| 96 | + | | `COOKIE_DOMAIN` (optionnel) | Cookie partagé entre sous-domaines | | |
| 121 | 97 | ||
| 122 | 98 | ## Documentation et Ressources | |
| 123 | 99 | ||
| @@ -76,48 +76,24 @@ Vous pouvez visualiser et gérer vos données via le dashboard Convex : | |||||
| 76 | npx convex dashboard | 76 | npx convex dashboard | ||
| 77 | ``` | 77 | ``` | ||
| 78 | 78 | ||||
| 79 | - | ## Déploiement Cloudflare Pages | 79 | + | ## Déploiement |
| 80 | 80 | ||||
| 81 | - | Le projet est déployé sur [Cloudflare Pages](https://pages.cloudflare.com/). Les Workers Cloudflare ont des **limitations strictes** à respecter. | 81 | + | Le site tourne dans une image conteneur Node ([`adapter-node`](https://svelte.dev/docs/kit/adapter-node)), écoutant sur le port `3000`. |
| 82 | 82 | ||||
| 83 | - | ### Opérations interdites au niveau global/module | 83 | + | ```bash |
| 84 | - | 84 | + | docker build -t pdraim . | |
| 85 | - | Cloudflare Workers **n'autorisent PAS** les opérations suivantes au niveau global (en dehors des handlers de requête) : | 85 | + | docker run --rm -p 3000:3000 --env-file .env.local -e ORIGIN=http://localhost:3000 pdraim |
| 86 | - | ||||
| 87 | - | ```typescript | |||
| 88 | - | // INTERDIT - provoque "Disallowed operation called within global scope" | |||
| 89 | - | setInterval(() => { ... }, 1000); | |||
| 90 | - | setTimeout(() => { ... }, 1000); | |||
| 91 | - | fetch('https://api.example.com'); | |||
| 92 | - | crypto.getRandomValues(new Uint8Array(16)); | |||
| 93 | - | ||||
| 94 | - | // AUTORISE - à l'intérieur d'un handler | |||
| 95 | - | export const GET: RequestHandler = async () => { | |||
| 96 | - | const random = crypto.getRandomValues(new Uint8Array(16)); | |||
| 97 | - | const response = await fetch('https://api.example.com'); | |||
| 98 | - | // ... | |||
| 99 | - | }; | |||
| 100 | ``` | 86 | ``` | ||
| 101 | 87 | ||||
| 102 | - | ### Bonnes pratiques | 88 | + | Toutes les variables sont lues au runtime (`$env/dynamic/*`) : la même image sert en local et en production. |
| 103 | - | ||||
| 104 | - | | A eviter | Alternative | | |||
| 105 | - | | ----------------------------------------------------- | ------------------------------------------------------ | | |||
| 106 | - | | `setInterval` pour cleanup | Lazy cleanup à chaque requête | | |||
| 107 | - | | `fetch()` au chargement du module | `fetch()` dans les handlers | | |||
| 108 | - | | Création de clients API au niveau module | Création lazy ou dans les handlers | | |||
| 109 | - | | `process.exit()` | Retourner une Response avec status d'erreur | | |||
| 110 | - | | Bibliothèques avec worker threads (ex: `pino-pretty`) | Versions compatibles edge (ex: `pino` sans transports) | | |||
| 111 | - | ||||
| 112 | - | ### Variables d'environnement | |||
| 113 | - | ||||
| 114 | - | - Utilisez `$env/dynamic/private` (runtime) au lieu de `$env/static/private` (build-time) | |||
| 115 | - | - Les variables définies dans le dashboard Cloudflare ne sont disponibles qu'au runtime | |||
| 116 | - | ||||
| 117 | - | ### Références | |||
| 118 | 89 | ||||
| 119 | - | - [Cloudflare Workers Runtime APIs](https://developers.cloudflare.com/workers/runtime-apis/) | 90 | + | | Variable | Rôle | |
| 120 | - | - [SvelteKit Cloudflare Adapter](https://svelte.dev/docs/kit/adapter-cloudflare) | 91 | + | | --------------------------------------------------- | --------------------------------------- | |
| 92 | + | | `PUBLIC_CONVEX_URL`, `CONVEX_API_SECRET` | Accès à Convex | | |||
| 93 | + | | `PUBLIC_TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile | | |||
| 94 | + | | `ORIGIN` | URL publique (ex. `https://pdraim.org`) | | |||
| 95 | + | | `ADDRESS_HEADER` (ex. `x-real-ip`) | IP client derrière les reverse proxies | | |||
| 96 | + | | `COOKIE_DOMAIN` (optionnel) | Cookie partagé entre sous-domaines | | |||
| 121 | 97 | ||||
| 122 | ## Documentation et Ressources | 98 | ## Documentation et Ressources | ||
| 123 | 99 | ||||
D _headers +0 -25
| @@ -1,25 +0,0 @@ | |||
| 1 | - | # Cache configuration for static assets | |
| 2 | - | ||
| 3 | - | # Font files - cache for 1 year (31536000 seconds) | |
| 4 | - | /fonts/* | |
| 5 | - | Cache-Control: public, max-age=31536000, immutable | |
| 6 | - | Access-Control-Allow-Origin: * | |
| 7 | - | Cross-Origin-Resource-Policy: cross-origin | |
| 8 | - | ||
| 9 | - | # General static assets - cache for 1 month (2592000 seconds) | |
| 10 | - | /favicon.png | |
| 11 | - | Cache-Control: public, max-age=2592000 | |
| 12 | - | ||
| 13 | - | # Icons and images - cache for 1 month | |
| 14 | - | /aim/* | |
| 15 | - | Cache-Control: public, max-age=2592000 | |
| 16 | - | ||
| 17 | - | /desktop/* | |
| 18 | - | Cache-Control: public, max-age=2592000 | |
| 19 | - | ||
| 20 | - | # Optional AIM samples and smileys (only present when dropped in locally) | |
| 21 | - | /sounds/* | |
| 22 | - | Cache-Control: public, max-age=2592000 | |
| 23 | - | ||
| 24 | - | /smileys/* | |
| 25 | - | Cache-Control: public, max-age=2592000⏎̸ | |
| @@ -1,25 +0,0 @@ | |||||
| 1 | - | # Cache configuration for static assets | |||
| 2 | - | ||||
| 3 | - | # Font files - cache for 1 year (31536000 seconds) | |||
| 4 | - | /fonts/* | |||
| 5 | - | Cache-Control: public, max-age=31536000, immutable | |||
| 6 | - | Access-Control-Allow-Origin: * | |||
| 7 | - | Cross-Origin-Resource-Policy: cross-origin | |||
| 8 | - | ||||
| 9 | - | # General static assets - cache for 1 month (2592000 seconds) | |||
| 10 | - | /favicon.png | |||
| 11 | - | Cache-Control: public, max-age=2592000 | |||
| 12 | - | ||||
| 13 | - | # Icons and images - cache for 1 month | |||
| 14 | - | /aim/* | |||
| 15 | - | Cache-Control: public, max-age=2592000 | |||
| 16 | - | ||||
| 17 | - | /desktop/* | |||
| 18 | - | Cache-Control: public, max-age=2592000 | |||
| 19 | - | ||||
| 20 | - | # Optional AIM samples and smileys (only present when dropped in locally) | |||
| 21 | - | /sounds/* | |||
| 22 | - | Cache-Control: public, max-age=2592000 | |||
| 23 | - | ||||
| 24 | - | /smileys/* | |||
| 25 | - | Cache-Control: public, max-age=2592000⏎̸ | |||
M package.json +22 -35
| @@ -16,64 +16,51 @@ | |||
| 16 | 16 | "format": "prettier --write .", | |
| 17 | 17 | "lint": "prettier --check . && eslint .", | |
| 18 | 18 | "test:unit": "vitest", | |
| 19 | - | "test": "npm run test:unit -- --run", | |
| 20 | - | "db:generate": "npx drizzle-kit generate", | |
| 21 | - | "db:push": "npx drizzle-kit push", | |
| 22 | - | "db:push-prod": "bash -c 'source .env && TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push'", | |
| 23 | - | "db:flush": "tsx utils/db-flush.ts" | |
| 19 | + | "test": "npm run test:unit -- --run" | |
| 24 | 20 | }, | |
| 25 | 21 | "devDependencies": { | |
| 26 | 22 | "@eslint/compat": "^2.1.1", | |
| 27 | 23 | "@eslint/js": "^10.0.1", | |
| 28 | - | "@sveltejs/adapter-cloudflare": "^7.2.9", | |
| 24 | + | "@lucide/svelte": "^0.564.0", | |
| 25 | + | "@sveltejs/adapter-node": "^5.5.7", | |
| 29 | 26 | "@sveltejs/kit": "^2.70.3", | |
| 30 | 27 | "@sveltejs/vite-plugin-svelte": "^6.2.4", | |
| 31 | 28 | "@tailwindcss/cli": "^4.3.3", | |
| 32 | 29 | "@tailwindcss/forms": "^0.5.11", | |
| 33 | 30 | "@tailwindcss/typography": "^0.5.20", | |
| 34 | 31 | "@tailwindcss/vite": "^4.3.3", | |
| 35 | - | "@types/node": "^25.9.8", | |
| 32 | + | "@types/node": "^25.9.9", | |
| 36 | 33 | "autoprefixer": "^10.6.1", | |
| 37 | - | "drizzle-kit": "^0.31.11", | |
| 38 | - | "eslint": "^10.11.0", | |
| 34 | + | "convex": "^1.46.0", | |
| 35 | + | "convex-helpers": "^0.1.128", | |
| 36 | + | "convex-svelte": "^0.0.12", | |
| 37 | + | "eslint": "^10.12.0", | |
| 39 | 38 | "eslint-config-prettier": "^10.1.8", | |
| 40 | - | "eslint-plugin-svelte": "^3.23.0", | |
| 41 | - | "globals": "^17.12.0", | |
| 39 | + | "eslint-plugin-svelte": "^3.23.1", | |
| 40 | + | "globals": "^17.13.0", | |
| 42 | 41 | "jsdom": "^28.1.0", | |
| 43 | - | "prettier": "^3.9.9", | |
| 42 | + | "pino": "^10.4.0", | |
| 43 | + | "prettier": "^3.9.10", | |
| 44 | 44 | "prettier-plugin-svelte": "^3.5.2", | |
| 45 | 45 | "prettier-plugin-tailwindcss": "^0.7.4", | |
| 46 | - | "svelte": "^5.57.1", | |
| 46 | + | "rehype": "^13.0.2", | |
| 47 | + | "rehype-sanitize": "^6.0.0", | |
| 48 | + | "rehype-stringify": "^10.0.1", | |
| 49 | + | "remark-parse": "^11.0.0", | |
| 50 | + | "remark-rehype": "^11.1.2", | |
| 51 | + | "svelte": "^5.57.2", | |
| 47 | 52 | "svelte-check": "^4.7.6", | |
| 48 | 53 | "tailwindcss": "^4.3.3", | |
| 49 | 54 | "terser": "^5.51.2", | |
| 50 | 55 | "tsx": "^4.23.15", | |
| 51 | 56 | "tw-animate-css": "^1.4.0", | |
| 52 | 57 | "typescript": "^5.9.3", | |
| 53 | - | "typescript-eslint": "^8.70.1", | |
| 54 | - | "vite": "^7.3.6", | |
| 55 | - | "vitest": "^4.1.11", | |
| 56 | - | "wrangler": "^4.141.0" | |
| 57 | - | }, | |
| 58 | - | "dependencies": { | |
| 59 | - | "@libsql/client": "^0.17.4", | |
| 60 | - | "@lucide/svelte": "^0.564.0", | |
| 61 | - | "convex": "^1.46.0", | |
| 62 | - | "convex-helpers": "^0.1.124", | |
| 63 | - | "convex-svelte": "^0.0.12", | |
| 64 | - | "dotenv": "^17.4.2", | |
| 65 | - | "drizzle-orm": "^0.45.3", | |
| 66 | - | "pino": "^10.3.1", | |
| 67 | - | "pino-pretty": "^13.1.3", | |
| 68 | - | "rehype": "^13.0.2", | |
| 69 | - | "rehype-sanitize": "^6.0.0", | |
| 70 | - | "rehype-stringify": "^10.0.1", | |
| 71 | - | "remark-parse": "^11.0.0", | |
| 72 | - | "remark-rehype": "^11.1.2", | |
| 58 | + | "typescript-eslint": "^8.71.1", | |
| 73 | 59 | "unified": "^11.0.5", | |
| 74 | - | "uuid": "^13.0.2", | |
| 60 | + | "vite": "^7.3.7", | |
| 61 | + | "vitest": "^4.1.11", | |
| 75 | 62 | "xp.css": "^0.2.6", | |
| 76 | - | "zod": "^4.6.5" | |
| 63 | + | "zod": "^4.6.6" | |
| 77 | 64 | }, | |
| 78 | 65 | "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501" | |
| 79 | 66 | } | |
| @@ -16,64 +16,51 @@ | |||||
| 16 | "format": "prettier --write .", | 16 | "format": "prettier --write .", | ||
| 17 | "lint": "prettier --check . && eslint .", | 17 | "lint": "prettier --check . && eslint .", | ||
| 18 | "test:unit": "vitest", | 18 | "test:unit": "vitest", | ||
| 19 | - | "test": "npm run test:unit -- --run", | 19 | + | "test": "npm run test:unit -- --run" |
| 20 | - | "db:generate": "npx drizzle-kit generate", | |||
| 21 | - | "db:push": "npx drizzle-kit push", | |||
| 22 | - | "db:push-prod": "bash -c 'source .env && TURSO_DATABASE_URL=\"$TURSO_DATABASE_URL_PROD\" TURSO_AUTH_TOKEN=\"$TURSO_AUTH_TOKEN_PROD\" npx drizzle-kit push'", | |||
| 23 | - | "db:flush": "tsx utils/db-flush.ts" | |||
| 24 | }, | 20 | }, | ||
| 25 | "devDependencies": { | 21 | "devDependencies": { | ||
| 26 | "@eslint/compat": "^2.1.1", | 22 | "@eslint/compat": "^2.1.1", | ||
| 27 | "@eslint/js": "^10.0.1", | 23 | "@eslint/js": "^10.0.1", | ||
| 28 | - | "@sveltejs/adapter-cloudflare": "^7.2.9", | 24 | + | "@lucide/svelte": "^0.564.0", |
| 25 | + | "@sveltejs/adapter-node": "^5.5.7", | |||
| 29 | "@sveltejs/kit": "^2.70.3", | 26 | "@sveltejs/kit": "^2.70.3", | ||
| 30 | "@sveltejs/vite-plugin-svelte": "^6.2.4", | 27 | "@sveltejs/vite-plugin-svelte": "^6.2.4", | ||
| 31 | "@tailwindcss/cli": "^4.3.3", | 28 | "@tailwindcss/cli": "^4.3.3", | ||
| 32 | "@tailwindcss/forms": "^0.5.11", | 29 | "@tailwindcss/forms": "^0.5.11", | ||
| 33 | "@tailwindcss/typography": "^0.5.20", | 30 | "@tailwindcss/typography": "^0.5.20", | ||
| 34 | "@tailwindcss/vite": "^4.3.3", | 31 | "@tailwindcss/vite": "^4.3.3", | ||
| 35 | - | "@types/node": "^25.9.8", | 32 | + | "@types/node": "^25.9.9", |
| 36 | "autoprefixer": "^10.6.1", | 33 | "autoprefixer": "^10.6.1", | ||
| 37 | - | "drizzle-kit": "^0.31.11", | 34 | + | "convex": "^1.46.0", |
| 38 | - | "eslint": "^10.11.0", | 35 | + | "convex-helpers": "^0.1.128", |
| 36 | + | "convex-svelte": "^0.0.12", | |||
| 37 | + | "eslint": "^10.12.0", | |||
| 39 | "eslint-config-prettier": "^10.1.8", | 38 | "eslint-config-prettier": "^10.1.8", | ||
| 40 | - | "eslint-plugin-svelte": "^3.23.0", | 39 | + | "eslint-plugin-svelte": "^3.23.1", |
| 41 | - | "globals": "^17.12.0", | 40 | + | "globals": "^17.13.0", |
| 42 | "jsdom": "^28.1.0", | 41 | "jsdom": "^28.1.0", | ||
| 43 | - | "prettier": "^3.9.9", | 42 | + | "pino": "^10.4.0", |
| 43 | + | "prettier": "^3.9.10", | |||
| 44 | "prettier-plugin-svelte": "^3.5.2", | 44 | "prettier-plugin-svelte": "^3.5.2", | ||
| 45 | "prettier-plugin-tailwindcss": "^0.7.4", | 45 | "prettier-plugin-tailwindcss": "^0.7.4", | ||
| 46 | - | "svelte": "^5.57.1", | 46 | + | "rehype": "^13.0.2", |
| 47 | + | "rehype-sanitize": "^6.0.0", | |||
| 48 | + | "rehype-stringify": "^10.0.1", | |||
| 49 | + | "remark-parse": "^11.0.0", | |||
| 50 | + | "remark-rehype": "^11.1.2", | |||
| 51 | + | "svelte": "^5.57.2", | |||
| 47 | "svelte-check": "^4.7.6", | 52 | "svelte-check": "^4.7.6", | ||
| 48 | "tailwindcss": "^4.3.3", | 53 | "tailwindcss": "^4.3.3", | ||
| 49 | "terser": "^5.51.2", | 54 | "terser": "^5.51.2", | ||
| 50 | "tsx": "^4.23.15", | 55 | "tsx": "^4.23.15", | ||
| 51 | "tw-animate-css": "^1.4.0", | 56 | "tw-animate-css": "^1.4.0", | ||
| 52 | "typescript": "^5.9.3", | 57 | "typescript": "^5.9.3", | ||
| 53 | - | "typescript-eslint": "^8.70.1", | 58 | + | "typescript-eslint": "^8.71.1", |
| 54 | - | "vite": "^7.3.6", | |||
| 55 | - | "vitest": "^4.1.11", | |||
| 56 | - | "wrangler": "^4.141.0" | |||
| 57 | - | }, | |||
| 58 | - | "dependencies": { | |||
| 59 | - | "@libsql/client": "^0.17.4", | |||
| 60 | - | "@lucide/svelte": "^0.564.0", | |||
| 61 | - | "convex": "^1.46.0", | |||
| 62 | - | "convex-helpers": "^0.1.124", | |||
| 63 | - | "convex-svelte": "^0.0.12", | |||
| 64 | - | "dotenv": "^17.4.2", | |||
| 65 | - | "drizzle-orm": "^0.45.3", | |||
| 66 | - | "pino": "^10.3.1", | |||
| 67 | - | "pino-pretty": "^13.1.3", | |||
| 68 | - | "rehype": "^13.0.2", | |||
| 69 | - | "rehype-sanitize": "^6.0.0", | |||
| 70 | - | "rehype-stringify": "^10.0.1", | |||
| 71 | - | "remark-parse": "^11.0.0", | |||
| 72 | - | "remark-rehype": "^11.1.2", | |||
| 73 | "unified": "^11.0.5", | 59 | "unified": "^11.0.5", | ||
| 74 | - | "uuid": "^13.0.2", | 60 | + | "vite": "^7.3.7", |
| 61 | + | "vitest": "^4.1.11", | |||
| 75 | "xp.css": "^0.2.6", | 62 | "xp.css": "^0.2.6", | ||
| 76 | - | "zod": "^4.6.5" | 63 | + | "zod": "^4.6.6" |
| 77 | }, | 64 | }, | ||
| 78 | "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501" | 65 | "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501" | ||
| 79 | } | 66 | } | ||
M pnpm-lock.yaml +650 -2317 generated file: collapsed
A server.js +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | // Production entry: adds cache headers for static assets, then delegates to SvelteKit. | |
| 2 | + | // Copied into the image next to build/ (see Dockerfile). | |
| 3 | + | import http from 'node:http'; | |
| 4 | + | import { handler } from './build/handler.js'; | |
| 5 | + | ||
| 6 | + | const host = process.env.HOST ?? '0.0.0.0'; | |
| 7 | + | const port = Number(process.env.PORT ?? 3000); | |
| 8 | + | ||
| 9 | + | const cacheRules = [ | |
| 10 | + | [/^\/fonts\//, 'public, max-age=31536000, immutable'], | |
| 11 | + | [/^\/(aim|desktop|sounds|smileys)\/|^\/favicon\.png$/, 'public, max-age=2592000'] | |
| 12 | + | ]; | |
| 13 | + | ||
| 14 | + | const server = http.createServer((req, res) => { | |
| 15 | + | const path = req.url?.split('?')[0] ?? '/'; | |
| 16 | + | const rule = cacheRules.find(([pattern]) => pattern.test(path)); | |
| 17 | + | if (rule) res.setHeader('Cache-Control', rule[1]); | |
| 18 | + | handler(req, res); | |
| 19 | + | }); | |
| 20 | + | ||
| 21 | + | server.listen(port, host, () => console.log(`Listening on http://${host}:${port}`)); | |
| 22 | + | ||
| 23 | + | for (const signal of ['SIGINT', 'SIGTERM']) { | |
| 24 | + | process.on(signal, () => server.close(() => process.exit(0))); | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | // Production entry: adds cache headers for static assets, then delegates to SvelteKit. | |||
| 2 | + | // Copied into the image next to build/ (see Dockerfile). | |||
| 3 | + | import http from 'node:http'; | |||
| 4 | + | import { handler } from './build/handler.js'; | |||
| 5 | + | ||||
| 6 | + | const host = process.env.HOST ?? '0.0.0.0'; | |||
| 7 | + | const port = Number(process.env.PORT ?? 3000); | |||
| 8 | + | ||||
| 9 | + | const cacheRules = [ | |||
| 10 | + | [/^\/fonts\//, 'public, max-age=31536000, immutable'], | |||
| 11 | + | [/^\/(aim|desktop|sounds|smileys)\/|^\/favicon\.png$/, 'public, max-age=2592000'] | |||
| 12 | + | ]; | |||
| 13 | + | ||||
| 14 | + | const server = http.createServer((req, res) => { | |||
| 15 | + | const path = req.url?.split('?')[0] ?? '/'; | |||
| 16 | + | const rule = cacheRules.find(([pattern]) => pattern.test(path)); | |||
| 17 | + | if (rule) res.setHeader('Cache-Control', rule[1]); | |||
| 18 | + | handler(req, res); | |||
| 19 | + | }); | |||
| 20 | + | ||||
| 21 | + | server.listen(port, host, () => console.log(`Listening on http://${host}:${port}`)); | |||
| 22 | + | ||||
| 23 | + | for (const signal of ['SIGINT', 'SIGTERM']) { | |||
| 24 | + | process.on(signal, () => server.close(() => process.exit(0))); | |||
| 25 | + | } | |||
M src/hooks.server.ts +6 -5
| @@ -34,7 +34,7 @@ async function setAllUsersOffline() { | |||
| 34 | 34 | } | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | - | // Initialize server (deferred, non-blocking for Cloudflare Workers compatibility) | |
| 37 | + | // Initialize server lazily on the first API request | |
| 38 | 38 | let initPromise: Promise<void> | null = null; | |
| 39 | 39 | let lastFailedAttempt = 0; | |
| 40 | 40 | let failureCount = 0; | |
| @@ -85,7 +85,6 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 85 | 85 | // CSRF protection: validate Origin header for state-changing requests | |
| 86 | 86 | if (event.request.method !== 'GET' && event.request.method !== 'HEAD') { | |
| 87 | 87 | const origin = event.request.headers.get('origin'); | |
| 88 | - | const host = event.request.headers.get('host'); | |
| 89 | 88 | ||
| 90 | 89 | if (!origin) { | |
| 91 | 90 | // Browsers always send Origin on state-changing requests. | |
| @@ -98,11 +97,12 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 98 | 97 | } | |
| 99 | 98 | ||
| 100 | 99 | try { | |
| 100 | + | // event.url comes from ORIGIN in production, so proxies rewriting Host do not matter | |
| 101 | 101 | const originUrl = new URL(origin); | |
| 102 | - | if (originUrl.host !== host) { | |
| 102 | + | if (originUrl.origin !== event.url.origin) { | |
| 103 | 103 | log.warn('CSRF check failed - origin mismatch', { | |
| 104 | - | origin: originUrl.host, | |
| 105 | - | host | |
| 104 | + | origin: originUrl.origin, | |
| 105 | + | expected: event.url.origin | |
| 106 | 106 | }); | |
| 107 | 107 | return new Response('Forbidden', { status: 403 }); | |
| 108 | 108 | } | |
| @@ -144,6 +144,7 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 144 | 144 | // Public routes that don't require authentication | |
| 145 | 145 | const publicRoutes = [ | |
| 146 | 146 | '/', | |
| 147 | + | '/api/health', | |
| 147 | 148 | '/api/session/login', | |
| 148 | 149 | '/api/session/validate', | |
| 149 | 150 | '/api/register', | |
| @@ -34,7 +34,7 @@ async function setAllUsersOffline() { | |||||
| 34 | } | 34 | } | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | - | // Initialize server (deferred, non-blocking for Cloudflare Workers compatibility) | 37 | + | // Initialize server lazily on the first API request |
| 38 | let initPromise: Promise<void> | null = null; | 38 | let initPromise: Promise<void> | null = null; | ||
| 39 | let lastFailedAttempt = 0; | 39 | let lastFailedAttempt = 0; | ||
| 40 | let failureCount = 0; | 40 | let failureCount = 0; | ||
| @@ -85,7 +85,6 @@ export const handle: Handle = async ({ event, resolve }) => { | |||||
| 85 | // CSRF protection: validate Origin header for state-changing requests | 85 | // CSRF protection: validate Origin header for state-changing requests | ||
| 86 | if (event.request.method !== 'GET' && event.request.method !== 'HEAD') { | 86 | if (event.request.method !== 'GET' && event.request.method !== 'HEAD') { | ||
| 87 | const origin = event.request.headers.get('origin'); | 87 | const origin = event.request.headers.get('origin'); | ||
| 88 | - | const host = event.request.headers.get('host'); | |||
| 89 | 88 | ||||
| 90 | if (!origin) { | 89 | if (!origin) { | ||
| 91 | // Browsers always send Origin on state-changing requests. | 90 | // Browsers always send Origin on state-changing requests. | ||
| @@ -98,11 +97,12 @@ export const handle: Handle = async ({ event, resolve }) => { | |||||
| 98 | } | 97 | } | ||
| 99 | 98 | ||||
| 100 | try { | 99 | try { | ||
| 100 | + | // event.url comes from ORIGIN in production, so proxies rewriting Host do not matter | |||
| 101 | const originUrl = new URL(origin); | 101 | const originUrl = new URL(origin); | ||
| 102 | - | if (originUrl.host !== host) { | 102 | + | if (originUrl.origin !== event.url.origin) { |
| 103 | log.warn('CSRF check failed - origin mismatch', { | 103 | log.warn('CSRF check failed - origin mismatch', { | ||
| 104 | - | origin: originUrl.host, | 104 | + | origin: originUrl.origin, |
| 105 | - | host | 105 | + | expected: event.url.origin |
| 106 | }); | 106 | }); | ||
| 107 | return new Response('Forbidden', { status: 403 }); | 107 | return new Response('Forbidden', { status: 403 }); | ||
| 108 | } | 108 | } | ||
| @@ -144,6 +144,7 @@ export const handle: Handle = async ({ event, resolve }) => { | |||||
| 144 | // Public routes that don't require authentication | 144 | // Public routes that don't require authentication | ||
| 145 | const publicRoutes = [ | 145 | const publicRoutes = [ | ||
| 146 | '/', | 146 | '/', | ||
| 147 | + | '/api/health', | |||
| 147 | '/api/session/login', | 148 | '/api/session/login', | ||
| 148 | '/api/session/validate', | 149 | '/api/session/validate', | ||
| 149 | '/api/register', | 150 | '/api/register', | ||
M src/lib/api/rate-limiter.ts +3 -3
| @@ -36,7 +36,7 @@ const MAX_RATE_LIMIT_DURATION = Math.max( | |||
| 36 | 36 | RATE_LIMITS.protected.durationMs | |
| 37 | 37 | ); | |
| 38 | 38 | ||
| 39 | - | // Lazy cleanup: remove stale entries on each check (Cloudflare Workers don't allow setInterval at global scope) | |
| 39 | + | // Lazy cleanup: remove stale entries on each check | |
| 40 | 40 | let lastCleanup = 0; | |
| 41 | 41 | ||
| 42 | 42 | function cleanupStaleEntries() { | |
| @@ -69,7 +69,6 @@ function isRateLimited( | |||
| 69 | 69 | ip: string, | |
| 70 | 70 | endpointType: keyof typeof RATE_LIMITS | |
| 71 | 71 | ): { limited: boolean; retryAfter?: number } { | |
| 72 | - | // Lazy cleanup on each check (replaces setInterval for Cloudflare compatibility) | |
| 73 | 72 | cleanupStaleEntries(); | |
| 74 | 73 | ||
| 75 | 74 | // Skip rate limiting completely in development for localhost | |
| @@ -99,7 +98,6 @@ function isRateLimited( | |||
| 99 | 98 | } | |
| 100 | 99 | ||
| 101 | 100 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | |
| 102 | - | const ip = event.getClientAddress(); | |
| 103 | 101 | const url = new URL(event.request.url); | |
| 104 | 102 | const isPublic = url.searchParams.get('public') === 'true'; | |
| 105 | 103 | ||
| @@ -108,6 +106,8 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n | |||
| 108 | 106 | return null; | |
| 109 | 107 | } | |
| 110 | 108 | ||
| 109 | + | const ip = event.getClientAddress(); | |
| 110 | + | ||
| 111 | 111 | const endpointType = getEndpointType(url.pathname, isPublic); | |
| 112 | 112 | const { limited, retryAfter } = isRateLimited(ip, endpointType); | |
| 113 | 113 | ||
| @@ -36,7 +36,7 @@ const MAX_RATE_LIMIT_DURATION = Math.max( | |||||
| 36 | RATE_LIMITS.protected.durationMs | 36 | RATE_LIMITS.protected.durationMs | ||
| 37 | ); | 37 | ); | ||
| 38 | 38 | ||||
| 39 | - | // Lazy cleanup: remove stale entries on each check (Cloudflare Workers don't allow setInterval at global scope) | 39 | + | // Lazy cleanup: remove stale entries on each check |
| 40 | let lastCleanup = 0; | 40 | let lastCleanup = 0; | ||
| 41 | 41 | ||||
| 42 | function cleanupStaleEntries() { | 42 | function cleanupStaleEntries() { | ||
| @@ -69,7 +69,6 @@ function isRateLimited( | |||||
| 69 | ip: string, | 69 | ip: string, | ||
| 70 | endpointType: keyof typeof RATE_LIMITS | 70 | endpointType: keyof typeof RATE_LIMITS | ||
| 71 | ): { limited: boolean; retryAfter?: number } { | 71 | ): { limited: boolean; retryAfter?: number } { | ||
| 72 | - | // Lazy cleanup on each check (replaces setInterval for Cloudflare compatibility) | |||
| 73 | cleanupStaleEntries(); | 72 | cleanupStaleEntries(); | ||
| 74 | 73 | ||||
| 75 | // Skip rate limiting completely in development for localhost | 74 | // Skip rate limiting completely in development for localhost | ||
| @@ -99,7 +98,6 @@ function isRateLimited( | |||||
| 99 | } | 98 | } | ||
| 100 | 99 | ||||
| 101 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | 100 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | ||
| 102 | - | const ip = event.getClientAddress(); | |||
| 103 | const url = new URL(event.request.url); | 101 | const url = new URL(event.request.url); | ||
| 104 | const isPublic = url.searchParams.get('public') === 'true'; | 102 | const isPublic = url.searchParams.get('public') === 'true'; | ||
| 105 | 103 | ||||
| @@ -108,6 +106,8 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n | |||||
| 108 | return null; | 106 | return null; | ||
| 109 | } | 107 | } | ||
| 110 | 108 | ||||
| 109 | + | const ip = event.getClientAddress(); | |||
| 110 | + | ||||
| 111 | const endpointType = getEndpointType(url.pathname, isPublic); | 111 | const endpointType = getEndpointType(url.pathname, isPublic); | ||
| 112 | const { limited, retryAfter } = isRateLimited(ip, endpointType); | 112 | const { limited, retryAfter } = isRateLimited(ip, endpointType); | ||
| 113 | 113 | ||||
M src/lib/api/session.cookie.ts +1 -19
| @@ -5,31 +5,13 @@ import { createLogger } from '$lib/utils/logger.server'; | |||
| 5 | 5 | ||
| 6 | 6 | const log = createLogger('session-cookie'); | |
| 7 | 7 | ||
| 8 | - | // Helper function to determine cookie domain | |
| 8 | + | // Host-only cookie by default; set COOKIE_DOMAIN for cross-subdomain cookies | |
| 9 | 9 | function getCookieDomain(): string | undefined { | |
| 10 | - | // Allow explicit override via environment variable | |
| 11 | 10 | const envDomain = env.COOKIE_DOMAIN; | |
| 12 | 11 | if (envDomain) { | |
| 13 | 12 | log.debug('Using COOKIE_DOMAIN from environment:', { domain: envDomain }); | |
| 14 | 13 | return envDomain; | |
| 15 | 14 | } | |
| 16 | - | ||
| 17 | - | if (!dev) { | |
| 18 | - | // Check for Cloudflare Pages domain | |
| 19 | - | if (env.CF_PAGES_URL) { | |
| 20 | - | const url = env.CF_PAGES_URL; | |
| 21 | - | log.debug('Cloudflare Pages URL:', { url }); | |
| 22 | - | // For preview deployments, use host-only cookie | |
| 23 | - | if (env.CF_PAGES_BRANCH !== 'main') { | |
| 24 | - | return undefined; | |
| 25 | - | } | |
| 26 | - | // For production on Cloudflare Pages, use host-only cookie (safest default) | |
| 27 | - | // Set COOKIE_DOMAIN explicitly if cross-subdomain cookies are needed | |
| 28 | - | return undefined; | |
| 29 | - | } | |
| 30 | - | // Production without explicit domain - use host-only cookie (safest default) | |
| 31 | - | return undefined; | |
| 32 | - | } | |
| 33 | 15 | return undefined; | |
| 34 | 16 | } | |
| 35 | 17 | ||
| @@ -5,31 +5,13 @@ import { createLogger } from '$lib/utils/logger.server'; | |||||
| 5 | 5 | ||||
| 6 | const log = createLogger('session-cookie'); | 6 | const log = createLogger('session-cookie'); | ||
| 7 | 7 | ||||
| 8 | - | // Helper function to determine cookie domain | 8 | + | // Host-only cookie by default; set COOKIE_DOMAIN for cross-subdomain cookies |
| 9 | function getCookieDomain(): string | undefined { | 9 | function getCookieDomain(): string | undefined { | ||
| 10 | - | // Allow explicit override via environment variable | |||
| 11 | const envDomain = env.COOKIE_DOMAIN; | 10 | const envDomain = env.COOKIE_DOMAIN; | ||
| 12 | if (envDomain) { | 11 | if (envDomain) { | ||
| 13 | log.debug('Using COOKIE_DOMAIN from environment:', { domain: envDomain }); | 12 | log.debug('Using COOKIE_DOMAIN from environment:', { domain: envDomain }); | ||
| 14 | return envDomain; | 13 | return envDomain; | ||
| 15 | } | 14 | } | ||
| 16 | - | ||||
| 17 | - | if (!dev) { | |||
| 18 | - | // Check for Cloudflare Pages domain | |||
| 19 | - | if (env.CF_PAGES_URL) { | |||
| 20 | - | const url = env.CF_PAGES_URL; | |||
| 21 | - | log.debug('Cloudflare Pages URL:', { url }); | |||
| 22 | - | // For preview deployments, use host-only cookie | |||
| 23 | - | if (env.CF_PAGES_BRANCH !== 'main') { | |||
| 24 | - | return undefined; | |||
| 25 | - | } | |||
| 26 | - | // For production on Cloudflare Pages, use host-only cookie (safest default) | |||
| 27 | - | // Set COOKIE_DOMAIN explicitly if cross-subdomain cookies are needed | |||
| 28 | - | return undefined; | |||
| 29 | - | } | |||
| 30 | - | // Production without explicit domain - use host-only cookie (safest default) | |||
| 31 | - | return undefined; | |||
| 32 | - | } | |||
| 33 | return undefined; | 15 | return undefined; | ||
| 34 | } | 16 | } | ||
| 35 | 17 | ||||
M src/lib/components/turnstile.svelte +2 -2
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import { onMount } from 'svelte'; | |
| 3 | 3 | import { fade } from 'svelte/transition'; | |
| 4 | - | import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; | |
| 4 | + | import { env } from '$env/dynamic/public'; | |
| 5 | 5 | import type { TurnstileOptions } from '$lib/types/turnstile'; | |
| 6 | 6 | ||
| 7 | 7 | let isVerified = $state(false); | |
| @@ -13,7 +13,7 @@ | |||
| 13 | 13 | let widgetId = ''; | |
| 14 | 14 | ||
| 15 | 15 | // Ensure site key is a string and trim any whitespace | |
| 16 | - | const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim(); | |
| 16 | + | const siteKey = String(env.PUBLIC_TURNSTILE_SITE_KEY ?? '').trim(); | |
| 17 | 17 | ||
| 18 | 18 | onMount(() => { | |
| 19 | 19 | if (!window.turnstile) { | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | import { onMount } from 'svelte'; | 2 | import { onMount } from 'svelte'; | ||
| 3 | import { fade } from 'svelte/transition'; | 3 | import { fade } from 'svelte/transition'; | ||
| 4 | - | import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'; | 4 | + | import { env } from '$env/dynamic/public'; |
| 5 | import type { TurnstileOptions } from '$lib/types/turnstile'; | 5 | import type { TurnstileOptions } from '$lib/types/turnstile'; | ||
| 6 | 6 | ||||
| 7 | let isVerified = $state(false); | 7 | let isVerified = $state(false); | ||
| @@ -13,7 +13,7 @@ | |||||
| 13 | let widgetId = ''; | 13 | let widgetId = ''; | ||
| 14 | 14 | ||||
| 15 | // Ensure site key is a string and trim any whitespace | 15 | // Ensure site key is a string and trim any whitespace | ||
| 16 | - | const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim(); | 16 | + | const siteKey = String(env.PUBLIC_TURNSTILE_SITE_KEY ?? '').trim(); |
| 17 | 17 | ||||
| 18 | onMount(() => { | 18 | onMount(() => { | ||
| 19 | if (!window.turnstile) { | 19 | if (!window.turnstile) { | ||
M src/lib/utils/logger.server.ts +3 -4
| @@ -1,10 +1,9 @@ | |||
| 1 | 1 | import pino from 'pino'; | |
| 2 | + | import { dev } from '$app/environment'; | |
| 2 | 3 | ||
| 3 | - | // Pino without transports works in Cloudflare Workers (no worker threads) | |
| 4 | - | // transports like pino-pretty use worker threads which don't exist in CF Workers | |
| 5 | - | // Use 'debug' level - filtering happens at log time based on env | |
| 4 | + | // JSON logs on stdout, collected by the container runtime | |
| 6 | 5 | const logger = pino({ | |
| 7 | - | level: 'debug', | |
| 6 | + | level: process.env.LOG_LEVEL ?? (dev ? 'debug' : 'info'), | |
| 8 | 7 | formatters: { | |
| 9 | 8 | level: (label) => ({ level: label }) | |
| 10 | 9 | } | |
| @@ -1,10 +1,9 @@ | |||||
| 1 | import pino from 'pino'; | 1 | import pino from 'pino'; | ||
| 2 | + | import { dev } from '$app/environment'; | |||
| 2 | 3 | ||||
| 3 | - | // Pino without transports works in Cloudflare Workers (no worker threads) | 4 | + | // JSON logs on stdout, collected by the container runtime |
| 4 | - | // transports like pino-pretty use worker threads which don't exist in CF Workers | |||
| 5 | - | // Use 'debug' level - filtering happens at log time based on env | |||
| 6 | const logger = pino({ | 5 | const logger = pino({ | ||
| 7 | - | level: 'debug', | 6 | + | level: process.env.LOG_LEVEL ?? (dev ? 'debug' : 'info'), |
| 8 | formatters: { | 7 | formatters: { | ||
| 9 | level: (label) => ({ level: label }) | 8 | level: (label) => ({ level: label }) | ||
| 10 | } | 9 | } | ||
M src/lib/utils/turnstile.server.ts +2 -2
| @@ -1,4 +1,4 @@ | |||
| 1 | - | import { TURNSTILE_SECRET_KEY } from '$env/static/private'; | |
| 1 | + | import { env } from '$env/dynamic/private'; | |
| 2 | 2 | import { dev } from '$app/environment'; | |
| 3 | 3 | import { createLogger } from './logger.server'; | |
| 4 | 4 | ||
| @@ -22,7 +22,7 @@ export async function validateTurnstileToken(token: string, remoteip?: string): | |||
| 22 | 22 | ||
| 23 | 23 | try { | |
| 24 | 24 | const formData = new FormData(); | |
| 25 | - | formData.append('secret', TURNSTILE_SECRET_KEY); | |
| 25 | + | formData.append('secret', env.TURNSTILE_SECRET_KEY ?? ''); | |
| 26 | 26 | formData.append('response', token); | |
| 27 | 27 | if (remoteip) { | |
| 28 | 28 | formData.append('remoteip', remoteip); | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | import { TURNSTILE_SECRET_KEY } from '$env/static/private'; | 1 | + | import { env } from '$env/dynamic/private'; |
| 2 | import { dev } from '$app/environment'; | 2 | import { dev } from '$app/environment'; | ||
| 3 | import { createLogger } from './logger.server'; | 3 | import { createLogger } from './logger.server'; | ||
| 4 | 4 | ||||
| @@ -22,7 +22,7 @@ export async function validateTurnstileToken(token: string, remoteip?: string): | |||||
| 22 | 22 | ||||
| 23 | try { | 23 | try { | ||
| 24 | const formData = new FormData(); | 24 | const formData = new FormData(); | ||
| 25 | - | formData.append('secret', TURNSTILE_SECRET_KEY); | 25 | + | formData.append('secret', env.TURNSTILE_SECRET_KEY ?? ''); |
| 26 | formData.append('response', token); | 26 | formData.append('response', token); | ||
| 27 | if (remoteip) { | 27 | if (remoteip) { | ||
| 28 | formData.append('remoteip', remoteip); | 28 | formData.append('remoteip', remoteip); | ||
A src/routes/api/health/+server.ts +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | import type { RequestHandler } from './$types'; | |
| 2 | + | ||
| 3 | + | export const GET: RequestHandler = () => new Response('ok'); | |
| @@ -0,0 +1,3 @@ | |||||
| 1 | + | import type { RequestHandler } from './$types'; | |||
| 2 | + | ||||
| 3 | + | export const GET: RequestHandler = () => new Response('ok'); | |||
M src/routes/api/register/+server.ts +1 -2
| @@ -10,7 +10,7 @@ const log = createLogger('register-server'); | |||
| 10 | 10 | // In-memory map to track failed captcha attempts per IP | |
| 11 | 11 | const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>(); | |
| 12 | 12 | ||
| 13 | - | // Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope | |
| 13 | + | // Lazy cleanup of stale captcha attempts | |
| 14 | 14 | const ONE_HOUR = 60 * 60 * 1000; | |
| 15 | 15 | let lastCleanup = 0; | |
| 16 | 16 | function cleanupOldCaptchaAttempts() { | |
| @@ -28,7 +28,6 @@ function cleanupOldCaptchaAttempts() { | |||
| 28 | 28 | export const POST: RequestHandler = async ({ request, getClientAddress }) => { | |
| 29 | 29 | log.debug('New registration attempt received'); | |
| 30 | 30 | ||
| 31 | - | // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility) | |
| 32 | 31 | cleanupOldCaptchaAttempts(); | |
| 33 | 32 | ||
| 34 | 33 | if (request.method !== 'POST') { | |
| @@ -10,7 +10,7 @@ const log = createLogger('register-server'); | |||||
| 10 | // In-memory map to track failed captcha attempts per IP | 10 | // In-memory map to track failed captcha attempts per IP | ||
| 11 | const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>(); | 11 | const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>(); | ||
| 12 | 12 | ||||
| 13 | - | // Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope | 13 | + | // Lazy cleanup of stale captcha attempts |
| 14 | const ONE_HOUR = 60 * 60 * 1000; | 14 | const ONE_HOUR = 60 * 60 * 1000; | ||
| 15 | let lastCleanup = 0; | 15 | let lastCleanup = 0; | ||
| 16 | function cleanupOldCaptchaAttempts() { | 16 | function cleanupOldCaptchaAttempts() { | ||
| @@ -28,7 +28,6 @@ function cleanupOldCaptchaAttempts() { | |||||
| 28 | export const POST: RequestHandler = async ({ request, getClientAddress }) => { | 28 | export const POST: RequestHandler = async ({ request, getClientAddress }) => { | ||
| 29 | log.debug('New registration attempt received'); | 29 | log.debug('New registration attempt received'); | ||
| 30 | 30 | ||||
| 31 | - | // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility) | |||
| 32 | cleanupOldCaptchaAttempts(); | 31 | cleanupOldCaptchaAttempts(); | ||
| 33 | 32 | ||||
| 34 | if (request.method !== 'POST') { | 33 | if (request.method !== 'POST') { | ||
M src/routes/api/session/login/+server.ts +1 -2
| @@ -17,7 +17,7 @@ const MAX_ATTEMPTS = 3; | |||
| 17 | 17 | const ONE_HOUR = 60 * 60 * 1000; | |
| 18 | 18 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| 19 | 19 | ||
| 20 | - | // Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope | |
| 20 | + | // Lazy cleanup of stale login attempts | |
| 21 | 21 | let lastCleanup = 0; | |
| 22 | 22 | function cleanupOldAttempts() { | |
| 23 | 23 | const now = Date.now(); | |
| @@ -34,7 +34,6 @@ function cleanupOldAttempts() { | |||
| 34 | 34 | export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { | |
| 35 | 35 | log.debug('New login attempt received'); | |
| 36 | 36 | ||
| 37 | - | // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility) | |
| 38 | 37 | cleanupOldAttempts(); | |
| 39 | 38 | ||
| 40 | 39 | if (request.method !== 'POST') { | |
| @@ -17,7 +17,7 @@ const MAX_ATTEMPTS = 3; | |||||
| 17 | const ONE_HOUR = 60 * 60 * 1000; | 17 | const ONE_HOUR = 60 * 60 * 1000; | ||
| 18 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | 18 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 19 | 19 | ||||
| 20 | - | // Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope | 20 | + | // Lazy cleanup of stale login attempts |
| 21 | let lastCleanup = 0; | 21 | let lastCleanup = 0; | ||
| 22 | function cleanupOldAttempts() { | 22 | function cleanupOldAttempts() { | ||
| 23 | const now = Date.now(); | 23 | const now = Date.now(); | ||
| @@ -34,7 +34,6 @@ function cleanupOldAttempts() { | |||||
| 34 | export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { | 34 | export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { | ||
| 35 | log.debug('New login attempt received'); | 35 | log.debug('New login attempt received'); | ||
| 36 | 36 | ||||
| 37 | - | // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility) | |||
| 38 | cleanupOldAttempts(); | 37 | cleanupOldAttempts(); | ||
| 39 | 38 | ||||
| 40 | if (request.method !== 'POST') { | 39 | if (request.method !== 'POST') { | ||
M svelte.config.js +1 -4
| @@ -1,4 +1,4 @@ | |||
| 1 | - | import adapter from '@sveltejs/adapter-cloudflare'; | |
| 1 | + | import adapter from '@sveltejs/adapter-node'; | |
| 2 | 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; | |
| 3 | 3 | ||
| 4 | 4 | /** @type {import('@sveltejs/kit').Config} */ | |
| @@ -8,9 +8,6 @@ const config = { | |||
| 8 | 8 | preprocess: vitePreprocess(), | |
| 9 | 9 | ||
| 10 | 10 | kit: { | |
| 11 | - | // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. | |
| 12 | - | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. | |
| 13 | - | // See https://svelte.dev/docs/kit/adapters for more information about adapters. | |
| 14 | 11 | adapter: adapter(), | |
| 15 | 12 | csp: { | |
| 16 | 13 | mode: 'auto', | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | import adapter from '@sveltejs/adapter-cloudflare'; | 1 | + | import adapter from '@sveltejs/adapter-node'; |
| 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; | 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; | ||
| 3 | 3 | ||||
| 4 | /** @type {import('@sveltejs/kit').Config} */ | 4 | /** @type {import('@sveltejs/kit').Config} */ | ||
| @@ -8,9 +8,6 @@ const config = { | |||||
| 8 | preprocess: vitePreprocess(), | 8 | preprocess: vitePreprocess(), | ||
| 9 | 9 | ||||
| 10 | kit: { | 10 | kit: { | ||
| 11 | - | // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. | |||
| 12 | - | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. | |||
| 13 | - | // See https://svelte.dev/docs/kit/adapters for more information about adapters. | |||
| 14 | adapter: adapter(), | 11 | adapter: adapter(), | ||
| 15 | csp: { | 12 | csp: { | ||
| 16 | mode: 'auto', | 13 | mode: 'auto', | ||
D wrangler.toml +0 -4
| @@ -1,4 +0,0 @@ | |||
| 1 | - | name = "pdraim" | |
| 2 | - | pages_build_output_dir = ".svelte-kit/cloudflare" | |
| 3 | - | compatibility_date = "2026-01-24" | |
| 4 | - | compatibility_flags = ["nodejs_compat"] | |
| @@ -1,4 +0,0 @@ | |||||
| 1 | - | name = "pdraim" | |||
| 2 | - | pages_build_output_dir = ".svelte-kit/cloudflare" | |||
| 3 | - | compatibility_date = "2026-01-24" | |||
| 4 | - | compatibility_flags = ["nodejs_compat"] | |||