detached f6d8405

Merge pull request 'feat(deploy): run pdraim as a Node container on Gordon' (#2) from feat/gordon-container into main

commit
f6d8405881b7ac6178972333e32c6c6fa59d9436
author
bnema · · 2026-10-12 13:16 UTC
merge of
3611559 + fb18f06 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
maintag: v0.4.0
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:
4040 update-types:
4141 - 'minor'
4242 - 'patch'
43- # Group Drizzle ORM related packages
44- drizzle:
45- patterns:
46- - 'drizzle-*'
47- - '@libsql/*'
48- update-types:
49- - 'minor'
50- - 'patch'
5143 # Set PR limits to avoid overwhelming the team
5244 open-pull-requests-limit: 8
5345 # 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 team43 # Set PR limits to avoid overwhelming the team
52 open-pull-requests-limit: 844 open-pull-requests-limit: 8
53 # Ignore packages that require manual updates or are pinned for specific reasons45 # Ignore packages that require manual updates or are pinned for specific reasons
M .github/workflows/ci.yml +0 -2
@@ -18,8 +18,6 @@ on:
1818
1919 env:
2020 # Dummy values for svelte-check type generation
21- TURSO_DATABASE_URL: 'libsql://dummy.turso.io'
22- TURSO_AUTH_TOKEN: 'dummy-token'
2321 TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA'
2422 PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA'
2523 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 generation20 # 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 @@
11 node_modules
2+.worktrees
23
34 # Output
45 .output
@@ -1,4 +1,5 @@
1 node_modules1 node_modules
2+.worktrees
2 3
3 # Output4 # Output
4 .output5 .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 :
7676 npx convex dashboard
7777 ```
7878
79-## Déploiement Cloudflare Pages
79+## Déploiement
8080
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`.
8282
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
10086 ```
10187
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.
11889
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 |
12197
12298 ## Documentation et Ressources
12399
@@ -76,48 +76,24 @@ Vous pouvez visualiser et gérer vos données via le dashboard Convex :
76 npx convex dashboard76 npx convex dashboard
77 ```77 ```
78 78
79-## Déploiement Cloudflare Pages79+## 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/module83+```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 pratiques88+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 Ressources98 ## 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 @@
1616 "format": "prettier --write .",
1717 "lint": "prettier --check . && eslint .",
1818 "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"
2420 },
2521 "devDependencies": {
2622 "@eslint/compat": "^2.1.1",
2723 "@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",
2926 "@sveltejs/kit": "^2.70.3",
3027 "@sveltejs/vite-plugin-svelte": "^6.2.4",
3128 "@tailwindcss/cli": "^4.3.3",
3229 "@tailwindcss/forms": "^0.5.11",
3330 "@tailwindcss/typography": "^0.5.20",
3431 "@tailwindcss/vite": "^4.3.3",
35- "@types/node": "^25.9.8",
32+ "@types/node": "^25.9.9",
3633 "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",
3938 "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",
4241 "jsdom": "^28.1.0",
43- "prettier": "^3.9.9",
42+ "pino": "^10.4.0",
43+ "prettier": "^3.9.10",
4444 "prettier-plugin-svelte": "^3.5.2",
4545 "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",
4752 "svelte-check": "^4.7.6",
4853 "tailwindcss": "^4.3.3",
4954 "terser": "^5.51.2",
5055 "tsx": "^4.23.15",
5156 "tw-animate-css": "^1.4.0",
5257 "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",
7359 "unified": "^11.0.5",
74- "uuid": "^13.0.2",
60+ "vite": "^7.3.7",
61+ "vitest": "^4.1.11",
7562 "xp.css": "^0.2.6",
76- "zod": "^4.6.5"
63+ "zod": "^4.6.6"
7764 },
7865 "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
7966 }
@@ -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() {
3434 }
3535 }
3636
37-// Initialize server (deferred, non-blocking for Cloudflare Workers compatibility)
37+// Initialize server lazily on the first API request
3838 let initPromise: Promise<void> | null = null;
3939 let lastFailedAttempt = 0;
4040 let failureCount = 0;
@@ -85,7 +85,6 @@ export const handle: Handle = async ({ event, resolve }) => {
8585 // CSRF protection: validate Origin header for state-changing requests
8686 if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
8787 const origin = event.request.headers.get('origin');
88- const host = event.request.headers.get('host');
8988
9089 if (!origin) {
9190 // Browsers always send Origin on state-changing requests.
@@ -98,11 +97,12 @@ export const handle: Handle = async ({ event, resolve }) => {
9897 }
9998
10099 try {
100+ // event.url comes from ORIGIN in production, so proxies rewriting Host do not matter
101101 const originUrl = new URL(origin);
102- if (originUrl.host !== host) {
102+ if (originUrl.origin !== event.url.origin) {
103103 log.warn('CSRF check failed - origin mismatch', {
104- origin: originUrl.host,
105- host
104+ origin: originUrl.origin,
105+ expected: event.url.origin
106106 });
107107 return new Response('Forbidden', { status: 403 });
108108 }
@@ -144,6 +144,7 @@ export const handle: Handle = async ({ event, resolve }) => {
144144 // Public routes that don't require authentication
145145 const publicRoutes = [
146146 '/',
147+ '/api/health',
147148 '/api/session/login',
148149 '/api/session/validate',
149150 '/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 requests85 // 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- host105+ 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 authentication144 // 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(
3636 RATE_LIMITS.protected.durationMs
3737 );
3838
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
4040 let lastCleanup = 0;
4141
4242 function cleanupStaleEntries() {
@@ -69,7 +69,6 @@ function isRateLimited(
6969 ip: string,
7070 endpointType: keyof typeof RATE_LIMITS
7171 ): { limited: boolean; retryAfter?: number } {
72- // Lazy cleanup on each check (replaces setInterval for Cloudflare compatibility)
7372 cleanupStaleEntries();
7473
7574 // Skip rate limiting completely in development for localhost
@@ -99,7 +98,6 @@ function isRateLimited(
9998 }
10099
101100 export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
102- const ip = event.getClientAddress();
103101 const url = new URL(event.request.url);
104102 const isPublic = url.searchParams.get('public') === 'true';
105103
@@ -108,6 +106,8 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
108106 return null;
109107 }
110108
109+ const ip = event.getClientAddress();
110+
111111 const endpointType = getEndpointType(url.pathname, isPublic);
112112 const { limited, retryAfter } = isRateLimited(ip, endpointType);
113113
@@ -36,7 +36,7 @@ const MAX_RATE_LIMIT_DURATION = Math.max(
36 RATE_LIMITS.protected.durationMs36 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_LIMITS70 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 localhost74 // 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';
55
66 const log = createLogger('session-cookie');
77
8-// Helper function to determine cookie domain
8+// Host-only cookie by default; set COOKIE_DOMAIN for cross-subdomain cookies
99 function getCookieDomain(): string | undefined {
10- // Allow explicit override via environment variable
1110 const envDomain = env.COOKIE_DOMAIN;
1211 if (envDomain) {
1312 log.debug('Using COOKIE_DOMAIN from environment:', { domain: envDomain });
1413 return envDomain;
1514 }
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- }
3315 return undefined;
3416 }
3517
@@ -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 domain8+// 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 @@
11 <script lang="ts">
22 import { onMount } from 'svelte';
33 import { fade } from 'svelte/transition';
4- import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public';
4+ import { env } from '$env/dynamic/public';
55 import type { TurnstileOptions } from '$lib/types/turnstile';
66
77 let isVerified = $state(false);
@@ -13,7 +13,7 @@
1313 let widgetId = '';
1414
1515 // 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();
1717
1818 onMount(() => {
1919 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 whitespace15 // 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 @@
11 import pino from 'pino';
2+import { dev } from '$app/environment';
23
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
65 const logger = pino({
7- level: 'debug',
6+ level: process.env.LOG_LEVEL ?? (dev ? 'debug' : 'info'),
87 formatters: {
98 level: (label) => ({ level: label })
109 }
@@ -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';
22 import { dev } from '$app/environment';
33 import { createLogger } from './logger.server';
44
@@ -22,7 +22,7 @@ export async function validateTurnstileToken(token: string, remoteip?: string):
2222
2323 try {
2424 const formData = new FormData();
25- formData.append('secret', TURNSTILE_SECRET_KEY);
25+ formData.append('secret', env.TURNSTILE_SECRET_KEY ?? '');
2626 formData.append('response', token);
2727 if (remoteip) {
2828 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');
1010 // In-memory map to track failed captcha attempts per IP
1111 const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>();
1212
13-// Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope
13+// Lazy cleanup of stale captcha attempts
1414 const ONE_HOUR = 60 * 60 * 1000;
1515 let lastCleanup = 0;
1616 function cleanupOldCaptchaAttempts() {
@@ -28,7 +28,6 @@ function cleanupOldCaptchaAttempts() {
2828 export const POST: RequestHandler = async ({ request, getClientAddress }) => {
2929 log.debug('New registration attempt received');
3030
31- // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility)
3231 cleanupOldCaptchaAttempts();
3332
3433 if (request.method !== 'POST') {
@@ -10,7 +10,7 @@ const log = createLogger('register-server');
10 // In-memory map to track failed captcha attempts per IP10 // 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 scope13+// 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;
1717 const ONE_HOUR = 60 * 60 * 1000;
1818 const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
1919
20-// Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope
20+// Lazy cleanup of stale login attempts
2121 let lastCleanup = 0;
2222 function cleanupOldAttempts() {
2323 const now = Date.now();
@@ -34,7 +34,6 @@ function cleanupOldAttempts() {
3434 export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
3535 log.debug('New login attempt received');
3636
37- // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility)
3837 cleanupOldAttempts();
3938
4039 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 scope20+// 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';
22 import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
33
44 /** @type {import('@sveltejs/kit').Config} */
@@ -8,9 +8,6 @@ const config = {
88 preprocess: vitePreprocess(),
99
1010 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.
1411 adapter: adapter(),
1512 csp: {
1613 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"]

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close