detached af344e1

feat(deploy): run as a Node container image instead of Cloudflare Pages

Use adapter-node, read Turnstile keys at runtime, add a Dockerfile, drop Pages-only files and cookie logic.
commit
af344e16dda96c8074677adaac47975c51ab9796
author
Kit · · 2026-10-12 12:31 UTC
parent
86d866d
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
13 files · +70 -73
A .dockerignore +13 -0
@@ -0,0 +1,13 @@
1+node_modules
2+.svelte-kit
3+build
4+.git
5+.worktrees
6+.env
7+.env.*
8+!.env.example
9+docs
10+.claude
11+.cursor
12+.serena
13+.wrangler
@@ -0,0 +1,13 @@
1+node_modules
2+.svelte-kit
3+build
4+.git
5+.worktrees
6+.env
7+.env.*
8+!.env.example
9+docs
10+.claude
11+.cursor
12+.serena
13+.wrangler
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 +32 -0
@@ -0,0 +1,32 @@
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+FROM node:22-alpine AS deps
12+WORKDIR /app
13+ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
14+RUN corepack enable
15+COPY package.json pnpm-lock.yaml ./
16+RUN pnpm install --prod --frozen-lockfile --ignore-scripts
17+
18+FROM node:22-alpine
19+WORKDIR /app
20+ENV NODE_ENV=production \
21+ HOST=0.0.0.0 \
22+ PORT=3000
23+
24+COPY --from=deps /app/node_modules ./node_modules
25+COPY --from=build /app/build ./build
26+COPY package.json ./
27+
28+USER node
29+EXPOSE 3000
30+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
31+ CMD node -e "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
32+CMD ["node", "build"]
@@ -0,0 +1,32 @@
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+FROM node:22-alpine AS deps
12+WORKDIR /app
13+ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
14+RUN corepack enable
15+COPY package.json pnpm-lock.yaml ./
16+RUN pnpm install --prod --frozen-lockfile --ignore-scripts
17+
18+FROM node:22-alpine
19+WORKDIR /app
20+ENV NODE_ENV=production \
21+ HOST=0.0.0.0 \
22+ PORT=3000
23+
24+COPY --from=deps /app/node_modules ./node_modules
25+COPY --from=build /app/build ./build
26+COPY package.json ./
27+
28+USER node
29+EXPOSE 3000
30+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
31+ CMD node -e "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
32+CMD ["node", "build"]
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`, `XFF_DEPTH` | 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`, `XFF_DEPTH` | 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
M src/hooks.server.ts +1 -1
@@ -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;
@@ -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;
M src/lib/api/rate-limiter.ts +1 -2
@@ -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
@@ -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
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 +1 -2
@@ -1,7 +1,6 @@
11 import pino from 'pino';
22
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
3+// JSON logs on stdout, collected by the container runtime
54 // Use 'debug' level - filtering happens at log time based on env
65 const logger = pino({
76 level: 'debug',
@@ -1,7 +1,6 @@
1 import pino from 'pino';1 import pino from 'pino';
2 2
3-// Pino without transports works in Cloudflare Workers (no worker threads)3+// 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 env4 // Use 'debug' level - filtering happens at log time based on env
6 const logger = pino({5 const logger = pino({
7 level: 'debug',6 level: 'debug',
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);
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',

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