From ddc641b59c692a178adc44bd0cb19020d9ae37dd Mon Sep 17 00:00:00 2001 From: Stanislas Bruhiere Date: Tue, 28 Jul 2026 13:12:12 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8(scaling)=20enable=20horizontal=20?= =?UTF-8?q?scaling=20via=20optional=20Redis=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ENVIRONMENT_VARIABLES.md | 10 +++++++++ docker-compose.yml | 17 ++++++++++++++ server/.env.sample | 7 ++++++ server/config/env/production.js | 17 ++++++++++---- server/package-lock.json | 39 +++++++++++++++++++++++++++++++++ server/package.json | 2 ++ 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/ENVIRONMENT_VARIABLES.md b/ENVIRONMENT_VARIABLES.md index 71f8dd1a..185e3d53 100644 --- a/ENVIRONMENT_VARIABLES.md +++ b/ENVIRONMENT_VARIABLES.md @@ -63,6 +63,16 @@ Only needed to store attachments/avatars/backgrounds on an S3-compatible bucket | `S3_BUCKET` | Bucket name. | | `S3_FORCE_PATH_STYLE` | Set to `true` for endpoints that require path-style requests (e.g. MinIO). | +### Horizontal scaling (multiple instances), optional + +Only needed when running **more than one** instance/process behind a load balancer. By default the app keeps sessions in memory and broadcasts realtime socket events per-process, so a second instance would not receive the live updates emitted by the first. Setting `REDIS_URL` shares both sessions and socket.io broadcasts through Redis (via `@sailshq/connect-redis` and `@sailshq/socket.io-redis`), which is required for the realtime collaboration to work across instances. Only applied in production (`NODE_ENV=production`). + +To scale out you must **also** move file storage off local disk to [S3](#object-storage-s3-optional) — otherwise attachments/avatars/backgrounds uploaded on one instance are not visible from the others. + +| Variable | Default | Description | +| ----------- | ------- | ----------------------------------------------------------------------------------------------------- | +| `REDIS_URL` | unset | Redis connection string (e.g. `redis://user:password@host:6379/0`). Enables shared sessions + sockets. | + ### OpenID Connect (OIDC) | Variable | Default | Description | diff --git a/docker-compose.yml b/docker-compose.yml index 293bcd59..bd096c97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,12 @@ services: # matching the value of this OIDC claim. Leave unset for free mode. # - ORGANIZATION_ID_CLAIM= + ### Horizontal scaling (multiple instances) + # Only needed to run more than one instance behind a load balancer: shares + # sessions and realtime socket broadcasts through Redis. Uncomment the + # `redis` service below as well, and configure S3 so uploads are shared too. + # - REDIS_URL=redis://redis:6379/0 + ### Object storage (S3) # - S3_ENDPOINT= # - S3_REGION= @@ -120,6 +126,17 @@ services: timeout: 5s retries: 5 + # Only needed for horizontal scaling (see REDIS_URL above). Uncomment to run + # several `projects` instances that share sessions and realtime broadcasts. + # redis: + # image: redis:7-alpine + # restart: on-failure + # healthcheck: + # test: ['CMD', 'redis-cli', 'ping'] + # interval: 10s + # timeout: 5s + # retries: 5 + volumes: user-avatars: project-background-images: diff --git a/server/.env.sample b/server/.env.sample index c258630a..fd9503c3 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -40,6 +40,13 @@ ALLOW_ALL_TO_CREATE_PROJECTS=true # users create and manage their own projects. # ORGANIZATION_ID_CLAIM= +### Horizontal scaling (multiple instances) + +# Required only when running more than one instance/process behind a load +# balancer: sessions and realtime socket broadcasts are then shared through +# Redis. Leave unset for a single-instance deployment. Only used in production. +# REDIS_URL=redis://localhost:6379/0 + ### Object storage (S3) # S3_ENDPOINT= diff --git a/server/config/env/production.js b/server/config/env/production.js index 61c89b70..2f9e5c1d 100644 --- a/server/config/env/production.js +++ b/server/config/env/production.js @@ -25,6 +25,12 @@ const { customLogger } = require('../../utils/logger'); const parsedBasedUrl = new URL(process.env.BASE_URL); +// When `REDIS_URL` is set, sessions and socket.io broadcasts are shared through +// Redis so the app can be scaled horizontally (run several instances/processes +// behind a load balancer). When it is unset, the app keeps its default single +// instance behaviour (in-memory session store, per-process socket broadcasts). +const redisUrl = process.env.REDIS_URL; + module.exports = { /** * @@ -168,8 +174,9 @@ module.exports = { * */ - // adapter: '@sailshq/connect-redis', - // url: 'redis://user:password@localhost:6379/databasenumber', + // Enabled automatically when `REDIS_URL` is provided (see top of file), so + // that a cluster of instances shares the same session store. + ...(redisUrl ? { adapter: '@sailshq/connect-redis', url: redisUrl } : {}), /** * @@ -236,8 +243,10 @@ module.exports = { * */ - // adapter: '@sailshq/socket.io-redis', - // url: 'redis://user:password@bigsquid.redistogo.com:9562/databasenumber', + // Enabled automatically when `REDIS_URL` is provided (see top of file), so + // that broadcasts (`sails.sockets.broadcast`) reach clients connected to any + // instance, not just the one that emitted them. + ...(redisUrl ? { adapter: '@sailshq/socket.io-redis', url: redisUrl } : {}), }, /** diff --git a/server/package-lock.json b/server/package-lock.json index 66ebdd10..3c3b4e30 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -7,6 +7,8 @@ "name": "projects-server", "dependencies": { "@aws-sdk/client-s3": "^3.698.0", + "@sailshq/connect-redis": "^6.1.3", + "@sailshq/socket.io-redis": "^6.1.2", "bcrypt": "^5.1.1", "dotenv": "^16.4.5", "dotenv-cli": "^7.4.4", @@ -1588,6 +1590,15 @@ "underscore": "1.13.1" } }, + "node_modules/@sailshq/connect-redis": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@sailshq/connect-redis/-/connect-redis-6.1.3.tgz", + "integrity": "sha512-KFIMY/rGW82aNDk2bp2qpxV1+t7S9OSre3LNrCNsUsBnddonq3DjGnVp88i4QFNTe8lcw3g7z0IHC3bEafMVJg==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/@sailshq/csurf": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@sailshq/csurf/-/csurf-1.11.1.tgz", @@ -1672,6 +1683,28 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.11.tgz", "integrity": "sha512-c0t+KCuUkO/YDLPG4WWzEwx3J5F/GHXsD1h/SNZfySqAIKe/BaP95x8fWtOfRJokpS5yYHRJjMtYlXD8jxnpbw==" }, + "node_modules/@sailshq/socket.io-redis": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@sailshq/socket.io-redis/-/socket.io-redis-6.1.2.tgz", + "integrity": "sha512-rSyq/1cBF23C5jQHhmGI/XIIlDZma4YRhXt3t4rmAyO2pvGsODBTJrJGhCg9InLvKJ5qhnu/77JcCD/0b1qPAQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.1", + "notepack.io": "~2.2.0", + "redis": "^3.0.0", + "socket.io-adapter": "~2.2.0", + "uid2": "0.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@sailshq/socket.io-redis/node_modules/socket.io-adapter": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.2.0.tgz", + "integrity": "sha512-rG49L+FwaVEwuAdeBRq49M97YI3ElVabJPzvHT9S6a2CWhDKnjSFasvwAwSYPRhQzfn4NtDIbCaGYgOCOU/rlg==", + "license": "MIT" + }, "node_modules/@smithy/abort-controller": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.8.tgz", @@ -6585,6 +6618,12 @@ "node": ">=0.10.0" } }, + "node_modules/notepack.io": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", + "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==", + "license": "MIT" + }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", diff --git a/server/package.json b/server/package.json index 8d2d398f..cd9062ae 100644 --- a/server/package.json +++ b/server/package.json @@ -30,6 +30,8 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.698.0", + "@sailshq/connect-redis": "^6.1.3", + "@sailshq/socket.io-redis": "^6.1.2", "bcrypt": "^5.1.1", "dotenv": "^16.4.5", "dotenv-cli": "^7.4.4", From 23eba8eaeb7817bd70db34af70125f84c4bb7ebc Mon Sep 17 00:00:00 2001 From: Stanislas Bruhiere Date: Tue, 28 Jul 2026 13:50:06 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B(build)=20fix=20ui-kit=20patch?= =?UTF-8?q?=20for=200.19.10=20and=20clarify=20scaling=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ENVIRONMENT_VARIABLES.md | 4 ++- .../@gouvfr-lasuite+ui-kit+0.19.10.patch | 27 +++++++++++++++++++ .../@gouvfr-lasuite+ui-kit+0.19.8.patch | 27 ------------------- docker-compose.yml | 14 +++++++--- server/.env.sample | 2 ++ .../user-board-preferences/upsert-one.js | 2 +- 6 files changed, 44 insertions(+), 32 deletions(-) create mode 100644 client/patches/@gouvfr-lasuite+ui-kit+0.19.10.patch delete mode 100644 client/patches/@gouvfr-lasuite+ui-kit+0.19.8.patch diff --git a/ENVIRONMENT_VARIABLES.md b/ENVIRONMENT_VARIABLES.md index 185e3d53..88ca4802 100644 --- a/ENVIRONMENT_VARIABLES.md +++ b/ENVIRONMENT_VARIABLES.md @@ -12,7 +12,7 @@ For a working starting point, see [`server/.env.sample`](./server/.env.sample) ( | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BASE_URL` | Public URL of the application (e.g. `https://projects.example.com`). Used to build absolute links (emails, webhooks, OIDC redirect/logout URIs) and to derive the base path/protocol. | | `DATABASE_URL` | PostgreSQL connection string (e.g. `postgresql://user:password@host:5432/db`). | -| `SECRET_KEY` | Secret used to sign session cookies. Use a long, random value in production. | +| `SECRET_KEY` | Secret used to sign session cookies. Use a long, random value in production. When running multiple replicas, they must all share the **same** value (see [Horizontal scaling](#horizontal-scaling-multiple-instances-optional)). | ### Core / general @@ -67,6 +67,8 @@ Only needed to store attachments/avatars/backgrounds on an S3-compatible bucket Only needed when running **more than one** instance/process behind a load balancer. By default the app keeps sessions in memory and broadcasts realtime socket events per-process, so a second instance would not receive the live updates emitted by the first. Setting `REDIS_URL` shares both sessions and socket.io broadcasts through Redis (via `@sailshq/connect-redis` and `@sailshq/socket.io-redis`), which is required for the realtime collaboration to work across instances. Only applied in production (`NODE_ENV=production`). +All replicas must sit behind an external load balancer pointed at the app's port (`1337` in the container) and share the **same `SECRET_KEY`**: session ID cookies are signed with it, so a cookie issued by one instance is only accepted by the others when the secret matches. They must likewise share the same `DATABASE_URL` and `REDIS_URL`. + To scale out you must **also** move file storage off local disk to [S3](#object-storage-s3-optional) — otherwise attachments/avatars/backgrounds uploaded on one instance are not visible from the others. | Variable | Default | Description | diff --git a/client/patches/@gouvfr-lasuite+ui-kit+0.19.10.patch b/client/patches/@gouvfr-lasuite+ui-kit+0.19.10.patch new file mode 100644 index 00000000..c381dd71 --- /dev/null +++ b/client/patches/@gouvfr-lasuite+ui-kit+0.19.10.patch @@ -0,0 +1,27 @@ +diff --git a/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js b/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js +index f13985b..bcd7389 100644 +--- a/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js ++++ b/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js +@@ -287,9 +287,9 @@ function k1() { + return Va || (Va = 1, process.env.NODE_ENV === "production" ? mn.exports = A1() : mn.exports = T1()), mn.exports; + } + var g = k1(); +-const I1 = { share: { copyLink: "Copy link", ok: "OK", shareButton: "Share", modalTitle: "Share folder", modalAriaLabel: "Share modal", access: { delete: "Remove access" }, cannot_view: { message: "You can view this item but you need additional access to view its members or modify the settings." }, invitations: { title: "Pending invitations" }, members: { title_plural: "Shared between {count} people", title_singular: "Shared between {count} person", load_more: "Show more " }, item: { add: "Add" }, search: { placeholder: "Search user", group_name: "Search user result" }, user: { no_result: "No result", placeholder: "Search for a user to invite" }, linkSettings: { title: "Link settings", reach: { choices: { public: { title: "Public", description: "Anyone with the link can access the document" }, authenticated: { title: "Authenticated", description: "Only authenticated users can access the document" }, restricted: { title: "Private", description: "Only users of the space can access the document" } } }, role: { choices: { reader: { title: "Reader" }, editor: { title: "Editor" } } } } }, laGaufre: { label: "Digital LaSuite services", closeLabel: "Close the menu", viewMoreLabel: "View more", viewLessLabel: "View less", loadingText: "Loading…", newWindowLabelSuffix: " (new window)", headerLabel: "About" }, userMenu: { term_of_service: "Terms of service", manage_account: "Manage account", open: "Open user menu", close: "Close user menu", accountSettings: "Account settings", logout: "Logout", dialogTitle: "User menu" }, treeView: { viewMore: "Load more elements" }, footer: { logo: { alt: "Logo of the French government" } }, onboarding: { skip: "Skip", next: "Next", previous: "Previous", complete: "Understood", stepLabel: "Step {current} of {total}: {title}", currentStepSuffix: " (current)", contentRegionLabel: "Step {current} of {total} content" } }, O1 = { ++const I1 = { share: { copyLink: "Copy link", ok: "OK", shareButton: "Share", modalTitle: "Share folder", modalAriaLabel: "Share modal", access: { delete: "Remove access" }, cannot_view: { message: "You can view this item but you need additional access to view its members or modify the settings." }, invitations: { title: "Pending invitations" }, members: { title_plural: "Shared between {count} people", title_singular: "Shared between {count} person", load_more: "Show more " }, item: { add: "Add" }, search: { placeholder: "Search user", group_name: "Search user result" }, user: { no_result: "No result, please use the whole email address to expand the search to all platform users", placeholder: "Search for a user to invite" }, linkSettings: { title: "Link settings", reach: { choices: { public: { title: "Public", description: "Anyone with the link can access the document" }, authenticated: { title: "Authenticated", description: "Only authenticated users can access the document" }, restricted: { title: "Private", description: "Only users of the space can access the document" } } }, role: { choices: { reader: { title: "Reader" }, editor: { title: "Editor" } } } } }, laGaufre: { label: "Digital LaSuite services", closeLabel: "Close the menu", viewMoreLabel: "View more", viewLessLabel: "View less", loadingText: "Loading…", newWindowLabelSuffix: " (new window)", headerLabel: "About" }, userMenu: { term_of_service: "Terms of service", manage_account: "Manage account", open: "Open user menu", close: "Close user menu", accountSettings: "Account settings", logout: "Logout", dialogTitle: "User menu" }, treeView: { viewMore: "Load more elements" }, footer: { logo: { alt: "Logo of the French government" } }, onboarding: { skip: "Skip", next: "Next", previous: "Previous", complete: "Understood", stepLabel: "Step {current} of {total}: {title}", currentStepSuffix: " (current)", contentRegionLabel: "Step {current} of {total} content" } }, O1 = { + components: I1 +-}, N1 = { share: { copyLink: "Copier le lien", ok: "OK", modalTitle: "Partage", modalAriaLabel: "Modale de partage", shareButton: "Partager", access: { delete: "Retirer l'accès" }, cannot_view: { message: "Vous pouvez voir cet élément mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres." }, invitations: { title: "Invitations en attente" }, members: { title_plural: "Partagé entre {count} personnes", title_singular: "Partagé entre {count} personne", load_more: "Afficher plus" }, item: { add: "Ajouter" }, search: { placeholder: "Rechercher un utilisateur", group_name: "Résultat de la recherche d'utilisateur" }, user: { no_result: "Aucun résultat", placeholder: "Rechercher un utilisateur à inviter" }, linkSettings: { title: "Paramètres du lien", reach: { choices: { public: { title: "Public", description: "N'importe qui avec le lien peut accéder au document" }, restricted: { title: "Privé", description: "Seules les personnes avec accès peuvent l'ouvrir à l'aide du lien" }, authenticated: { title: "Authenticated", description: "Seuls les utilisateurs authentifiés peuvent accéder au document" } } }, role: { choices: { reader: { title: "Lecture seule" }, editor: { title: "Édition" } } } } }, laGaufre: { label: "Services de la Suite numérique", closeLabel: "Fermer le menu", loadingText: "Chargement…", viewMoreLabel: "Voir plus", viewLessLabel: "Voir moins", newWindowLabelSuffix: " (nouvelle fenêtre)", headerLabel: "À propos" }, userMenu: { term_of_service: "Conditions d'utilisation", open: "Ouvrir le menu utilisateur", close: "Fermer le menu utilisateur", accountSettings: "Paramètres du compte", logout: "Déconnexion", dialogTitle: "Menu utilisateur", manage_account: "Gérer le compte" }, treeView: { viewMore: "Charger plus d'éléments" }, footer: { logo: { alt: "Logo du gouvernement français" } }, onboarding: { skip: "Passer", next: "Suivant", previous: "Précédent", complete: "Compris", stepLabel: "Étape {current} sur {total} : {title}", currentStepSuffix: " (actuelle)", contentRegionLabel: "Contenu de l'étape {current} sur {total}" } }, B1 = { ++}, N1 = { share: { copyLink: "Copier le lien", ok: "OK", modalTitle: "Partage", modalAriaLabel: "Modale de partage", shareButton: "Partager", access: { delete: "Retirer l'accès" }, cannot_view: { message: "Vous pouvez voir cet élément mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres." }, invitations: { title: "Invitations en attente" }, members: { title_plural: "Partagé entre {count} personnes", title_singular: "Partagé entre {count} personne", load_more: "Afficher plus" }, item: { add: "Ajouter" }, search: { placeholder: "Rechercher un utilisateur", group_name: "Résultat de la recherche d'utilisateur" }, user: { no_result: "Aucun résultat, veuillez utiliser l'adresse email complète pour élargir la recherche à tous les utilisateurs de la plateforme", placeholder: "Rechercher un utilisateur à inviter" }, linkSettings: { title: "Paramètres du lien", reach: { choices: { public: { title: "Public", description: "N'importe qui avec le lien peut accéder au document" }, restricted: { title: "Privé", description: "Seules les personnes avec accès peuvent l'ouvrir à l'aide du lien" }, authenticated: { title: "Authenticated", description: "Seuls les utilisateurs authentifiés peuvent accéder au document" } } }, role: { choices: { reader: { title: "Lecture seule" }, editor: { title: "Édition" } } } } }, laGaufre: { label: "Services de la Suite numérique", closeLabel: "Fermer le menu", loadingText: "Chargement…", viewMoreLabel: "Voir plus", viewLessLabel: "Voir moins", newWindowLabelSuffix: " (nouvelle fenêtre)", headerLabel: "À propos" }, userMenu: { term_of_service: "Conditions d'utilisation", open: "Ouvrir le menu utilisateur", close: "Fermer le menu utilisateur", accountSettings: "Paramètres du compte", logout: "Déconnexion", dialogTitle: "Menu utilisateur", manage_account: "Gérer le compte" }, treeView: { viewMore: "Charger plus d'éléments" }, footer: { logo: { alt: "Logo du gouvernement français" } }, onboarding: { skip: "Passer", next: "Suivant", previous: "Précédent", complete: "Compris", stepLabel: "Étape {current} sur {total} : {title}", currentStepSuffix: " (actuelle)", contentRegionLabel: "Contenu de l'étape {current} sur {total}" } }, B1 = { + components: N1 + }; + function Qi(t) { +@@ -21568,9 +21568,7 @@ const La = ({ + (X) => !I.includes(X) + ); + let V = m(E !== "" ? "components.share.user.no_result" : "components.share.user.placeholder"); +- const Q = ((X) => !!X.match( +- /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z\-0-9]{2,}))$/ +- ))(E ?? "") && !(R != null && R.some((X) => X.email === E)), te = { ++ const Q = false, te = { + id: E, + full_name: "", + email: E diff --git a/client/patches/@gouvfr-lasuite+ui-kit+0.19.8.patch b/client/patches/@gouvfr-lasuite+ui-kit+0.19.8.patch deleted file mode 100644 index 7265439b..00000000 --- a/client/patches/@gouvfr-lasuite+ui-kit+0.19.8.patch +++ /dev/null @@ -1,27 +0,0 @@ -diff --git a/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js b/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js -index ab2c51e..efa072b 100644 ---- a/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js -+++ b/node_modules/@gouvfr-lasuite/ui-kit/dist/index.js -@@ -287,9 +287,9 @@ function k1() { - return Va || (Va = 1, process.env.NODE_ENV === "production" ? mn.exports = A1() : mn.exports = T1()), mn.exports; - } - var g = k1(); --const I1 = { share: { copyLink: "Copy link", ok: "OK", shareButton: "Share", modalTitle: "Share folder", modalAriaLabel: "Share modal", access: { delete: "Remove access" }, cannot_view: { message: "You can view this item but you need additional access to view its members or modify the settings." }, invitations: { title: "Pending invitations" }, members: { title_plural: "Shared between {count} people", title_singular: "Shared between {count} person", load_more: "Show more " }, item: { add: "Add" }, search: { placeholder: "Search user", group_name: "Search user result" }, user: { no_result: "No result", placeholder: "Search user" }, linkSettings: { title: "Link settings", reach: { choices: { public: { title: "Public", description: "Anyone with the link can access the document" }, authenticated: { title: "Authenticated", description: "Only authenticated users can access the document" }, restricted: { title: "Private", description: "Only users of the space can access the document" } } }, role: { choices: { reader: { title: "Reader" }, editor: { title: "Editor" } } } } }, laGaufre: { label: "Digital LaSuite services", closeLabel: "Close the menu", viewMoreLabel: "View more", viewLessLabel: "View less", loadingText: "Loading…", newWindowLabelSuffix: " (new window)", headerLabel: "About" }, userMenu: { term_of_service: "Terms of service", manage_account: "Manage account", open: "Open user menu", close: "Close user menu", accountSettings: "Account settings", logout: "Logout", dialogTitle: "User menu" }, treeView: { viewMore: "Load more elements" }, footer: { logo: { alt: "Logo of the French government" } }, onboarding: { skip: "Skip", next: "Next", previous: "Previous", complete: "Understood", stepLabel: "Step {current} of {total}: {title}", currentStepSuffix: " (current)", contentRegionLabel: "Step {current} of {total} content" } }, O1 = { -+const I1 = { share: { copyLink: "Copy link", ok: "OK", shareButton: "Share", modalTitle: "Share folder", modalAriaLabel: "Share modal", access: { delete: "Remove access" }, cannot_view: { message: "You can view this item but you need additional access to view its members or modify the settings." }, invitations: { title: "Pending invitations" }, members: { title_plural: "Shared between {count} people", title_singular: "Shared between {count} person", load_more: "Show more " }, item: { add: "Add" }, search: { placeholder: "Search user", group_name: "Search user result" }, user: { no_result: "No result, please use the whole email address to expand the search to all platform users", placeholder: "Search user" }, linkSettings: { title: "Link settings", reach: { choices: { public: { title: "Public", description: "Anyone with the link can access the document" }, authenticated: { title: "Authenticated", description: "Only authenticated users can access the document" }, restricted: { title: "Private", description: "Only users of the space can access the document" } } }, role: { choices: { reader: { title: "Reader" }, editor: { title: "Editor" } } } } }, laGaufre: { label: "Digital LaSuite services", closeLabel: "Close the menu", viewMoreLabel: "View more", viewLessLabel: "View less", loadingText: "Loading…", newWindowLabelSuffix: " (new window)", headerLabel: "About" }, userMenu: { term_of_service: "Terms of service", manage_account: "Manage account", open: "Open user menu", close: "Close user menu", accountSettings: "Account settings", logout: "Logout", dialogTitle: "User menu" }, treeView: { viewMore: "Load more elements" }, footer: { logo: { alt: "Logo of the French government" } }, onboarding: { skip: "Skip", next: "Next", previous: "Previous", complete: "Understood", stepLabel: "Step {current} of {total}: {title}", currentStepSuffix: " (current)", contentRegionLabel: "Step {current} of {total} content" } }, O1 = { - components: I1 --}, N1 = { share: { copyLink: "Copier le lien", ok: "OK", modalTitle: "Partage", modalAriaLabel: "Modale de partage", shareButton: "Partager", access: { delete: "Retirer l'accès" }, cannot_view: { message: "Vous pouvez voir cet élément mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres." }, invitations: { title: "Invitations en attente" }, members: { title_plural: "Partagé entre {count} personnes", title_singular: "Partagé entre {count} personne", load_more: "Afficher plus" }, item: { add: "Ajouter" }, search: { placeholder: "Rechercher un utilisateur", group_name: "Résultat de la recherche d'utilisateur" }, user: { no_result: "Aucun résultat", placeholder: "Rechercher un utilisateur" }, linkSettings: { title: "Paramètres du lien", reach: { choices: { public: { title: "Public", description: "N'importe qui avec le lien peut accéder au document" }, restricted: { title: "Privé", description: "Seules les personnes avec accès peuvent l'ouvrir à l'aide du lien" }, authenticated: { title: "Authenticated", description: "Seuls les utilisateurs authentifiés peuvent accéder au document" } } }, role: { choices: { reader: { title: "Lecture seule" }, editor: { title: "Édition" } } } } }, laGaufre: { label: "Services de la Suite numérique", closeLabel: "Fermer le menu", loadingText: "Chargement…", viewMoreLabel: "Voir plus", viewLessLabel: "Voir moins", newWindowLabelSuffix: " (nouvelle fenêtre)", headerLabel: "À propos" }, userMenu: { term_of_service: "Conditions d'utilisation", open: "Ouvrir le menu utilisateur", close: "Fermer le menu utilisateur", accountSettings: "Paramètres du compte", logout: "Déconnexion", dialogTitle: "Menu utilisateur", manage_account: "Gérer le compte" }, treeView: { viewMore: "Charger plus d'éléments" }, footer: { logo: { alt: "Logo du gouvernement français" } }, onboarding: { skip: "Passer", next: "Suivant", previous: "Précédent", complete: "Compris", stepLabel: "Étape {current} sur {total} : {title}", currentStepSuffix: " (actuelle)", contentRegionLabel: "Contenu de l'étape {current} sur {total}" } }, B1 = { -+}, N1 = { share: { copyLink: "Copier le lien", ok: "OK", modalTitle: "Partage", modalAriaLabel: "Modale de partage", shareButton: "Partager", access: { delete: "Retirer l'accès" }, cannot_view: { message: "Vous pouvez voir cet élément mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres." }, invitations: { title: "Invitations en attente" }, members: { title_plural: "Partagé entre {count} personnes", title_singular: "Partagé entre {count} personne", load_more: "Afficher plus" }, item: { add: "Ajouter" }, search: { placeholder: "Rechercher un utilisateur", group_name: "Résultat de la recherche d'utilisateur" }, user: { no_result: "Aucun résultat, veuillez utiliser l'adresse email complète pour élargir la recherche à tous les utilisateurs de la plateforme", placeholder: "Rechercher un utilisateur" }, linkSettings: { title: "Paramètres du lien", reach: { choices: { public: { title: "Public", description: "N'importe qui avec le lien peut accéder au document" }, restricted: { title: "Privé", description: "Seules les personnes avec accès peuvent l'ouvrir à l'aide du lien" }, authenticated: { title: "Authenticated", description: "Seuls les utilisateurs authentifiés peuvent accéder au document" } } }, role: { choices: { reader: { title: "Lecture seule" }, editor: { title: "Édition" } } } } }, laGaufre: { label: "Services de la Suite numérique", closeLabel: "Fermer le menu", loadingText: "Chargement…", viewMoreLabel: "Voir plus", viewLessLabel: "Voir moins", newWindowLabelSuffix: " (nouvelle fenêtre)", headerLabel: "À propos" }, userMenu: { term_of_service: "Conditions d'utilisation", open: "Ouvrir le menu utilisateur", close: "Fermer le menu utilisateur", accountSettings: "Paramètres du compte", logout: "Déconnexion", dialogTitle: "Menu utilisateur", manage_account: "Gérer le compte" }, treeView: { viewMore: "Charger plus d'éléments" }, footer: { logo: { alt: "Logo du gouvernement français" } }, onboarding: { skip: "Passer", next: "Suivant", previous: "Précédent", complete: "Compris", stepLabel: "Étape {current} sur {total} : {title}", currentStepSuffix: " (actuelle)", contentRegionLabel: "Contenu de l'étape {current} sur {total}" } }, B1 = { - components: N1 - }; - function Qi(t) { -@@ -21568,9 +21568,7 @@ const La = ({ - (X) => !I.includes(X) - ); - let V = m(E !== "" ? "components.share.user.no_result" : "components.share.user.placeholder"); -- const Q = ((X) => !!X.match( -- /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z\-0-9]{2,}))$/ -- ))(E ?? "") && !(R != null && R.some((X) => X.email === E)), te = { -+ const Q = false, te = { - id: E, - full_name: "", - email: E diff --git a/docker-compose.yml b/docker-compose.yml index bd096c97..7eef3f5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,9 +55,13 @@ services: # - ORGANIZATION_ID_CLAIM= ### Horizontal scaling (multiple instances) - # Only needed to run more than one instance behind a load balancer: shares - # sessions and realtime socket broadcasts through Redis. Uncomment the - # `redis` service below as well, and configure S3 so uploads are shared too. + # This file defines a SINGLE instance (bound to host port 3000). To scale + # out, run several `projects` instances behind an external load balancer + # pointed at the container port 1337 — do not rely on `docker compose + # --scale`, which cannot republish the fixed host port per replica. Every + # replica must share this Redis, the same DATABASE_URL, and the same + # SECRET_KEY (used to sign session cookies), plus S3 so uploads are shared. + # Uncomment the `redis` service (and its depends_on) below as well. # - REDIS_URL=redis://redis:6379/0 ### Object storage (S3) @@ -111,6 +115,10 @@ services: depends_on: postgres: condition: service_healthy + # Uncomment together with the `redis` service below when scaling horizontally, + # so startup waits for Redis to be ready. + # redis: + # condition: service_healthy postgres: image: postgres:16-alpine diff --git a/server/.env.sample b/server/.env.sample index fd9503c3..9dfe2f57 100644 --- a/server/.env.sample +++ b/server/.env.sample @@ -45,6 +45,8 @@ ALLOW_ALL_TO_CREATE_PROJECTS=true # Required only when running more than one instance/process behind a load # balancer: sessions and realtime socket broadcasts are then shared through # Redis. Leave unset for a single-instance deployment. Only used in production. +# All replicas must also share the SAME SECRET_KEY (above) so session cookies +# signed by one instance are accepted by the others. # REDIS_URL=redis://localhost:6379/0 ### Object storage (S3) diff --git a/server/api/helpers/user-board-preferences/upsert-one.js b/server/api/helpers/user-board-preferences/upsert-one.js index 02d9c812..8b4efaeb 100644 --- a/server/api/helpers/user-board-preferences/upsert-one.js +++ b/server/api/helpers/user-board-preferences/upsert-one.js @@ -51,7 +51,7 @@ module.exports = { // Determine target folder for position calculation const targetFolderId = !_.isUndefined(values.folderId) ? values.folderId - : existingPreference?.folderId || null; + : (existingPreference && existingPreference.folderId) || null; // Get other boards in the same context (same folder or root) for this user const otherPreferences = await UserBoardPreference.find({ From 03251770bef1f1ce749760b288c780dec44f4ffe Mon Sep 17 00:00:00 2001 From: Stanislas Bruhiere Date: Fri, 31 Jul 2026 11:52:12 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B(build)=20unblock=20CI=20(patch?= =?UTF-8?q?,=20lint,=20build)=20and=20clarify=20scaling=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ENVIRONMENT_VARIABLES.md | 2 +- .../src/components/BoardActions/BoardActions.jsx | 2 -- .../BoardTree/BoardTreeItem/BoardTreeItem.jsx | 2 -- .../steps/BoardActionsStep/BoardActionsStep.jsx | 13 +------------ .../NotificationsStep/NotificationsStep.jsx | 1 - docker-compose.yml | 16 ++++++++++------ 6 files changed, 12 insertions(+), 24 deletions(-) diff --git a/ENVIRONMENT_VARIABLES.md b/ENVIRONMENT_VARIABLES.md index 88ca4802..5b240084 100644 --- a/ENVIRONMENT_VARIABLES.md +++ b/ENVIRONMENT_VARIABLES.md @@ -67,7 +67,7 @@ Only needed to store attachments/avatars/backgrounds on an S3-compatible bucket Only needed when running **more than one** instance/process behind a load balancer. By default the app keeps sessions in memory and broadcasts realtime socket events per-process, so a second instance would not receive the live updates emitted by the first. Setting `REDIS_URL` shares both sessions and socket.io broadcasts through Redis (via `@sailshq/connect-redis` and `@sailshq/socket.io-redis`), which is required for the realtime collaboration to work across instances. Only applied in production (`NODE_ENV=production`). -All replicas must sit behind an external load balancer pointed at the app's port (`1337` in the container) and share the **same `SECRET_KEY`**: session ID cookies are signed with it, so a cookie issued by one instance is only accepted by the others when the secret matches. They must likewise share the same `DATABASE_URL` and `REDIS_URL`. +All replicas must sit behind a load balancer that forwards to each instance's app port (`1337`) and share the **same `SECRET_KEY`**: session ID cookies are signed with it, so a cookie issued by one instance is only accepted by the others when the secret matches. They must likewise share the same `DATABASE_URL` and `REDIS_URL`. (How the balancer reaches that port — a published host port vs. the container directly — depends on your deployment; see [`docker-compose.yml`](./docker-compose.yml) for the Docker topology.) To scale out you must **also** move file storage off local disk to [S3](#object-storage-s3-optional) — otherwise attachments/avatars/backgrounds uploaded on one instance are not visible from the others. diff --git a/client/src/components/BoardActions/BoardActions.jsx b/client/src/components/BoardActions/BoardActions.jsx index 4ef0ecb8..235a7297 100644 --- a/client/src/components/BoardActions/BoardActions.jsx +++ b/client/src/components/BoardActions/BoardActions.jsx @@ -52,7 +52,6 @@ const BoardActions = React.memo( const [t] = useTranslation(); const [isShareModalOpen, setIsShareModalOpen] = useState(false); - const [isBoardActionsPopoverOpen, setIsBoardActionsPopoverOpen] = useState(false); const BoardActionsPopover = usePopup(BoardActionsStep); @@ -235,7 +234,6 @@ const BoardActions = React.memo( onDelete={() => {}} onLeave={() => {}} onExportCsv={handleExportCsv} - onOpenChange={setIsBoardActionsPopoverOpen} >