From b673367c6ff8a698e8e83571397d3dfaa056a14a Mon Sep 17 00:00:00 2001 From: Yuriy Panov Date: Sat, 29 Aug 2026 15:57:24 +0600 Subject: [PATCH] Prepare the four landings for production deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes that block or weaken a real deployment. Nothing here changes the rendered pages. Bind the lead API to loopback. Each server called app.listen() without a host, so it bound 0.0.0.0. Combined with a blanket `trust proxy: true` — which makes Express take the leftmost X-Forwarded-For entry as req.ip — the in-memory lead rate limiter was spoofable by anyone who could reach the port directly. HOST now defaults to 127.0.0.1 and trust is narrowed to 'loopback', so a request arriving from anywhere but the local proxy has its forged header ignored. Give each landing its own port. All four .env files claimed PORT=3000, and fitnes/.env.example collided with medcenter/.env.example, so three of the four could never have started on one host. Now 3000/3001/3002/3003 consistently across the code defaults, the env templates and the vite dev proxies, so all four also run side by side locally. Template the JSON-LD url. canonical and og:url already resolved from %VITE_SITE_URL%, but the JSON-LD block hardcoded an exodevices.ru sub-path that would not follow the environment. All four now read from the same variable. Declare the Node version. Nothing stated it, yet transitive deps impose a >=22.12 floor (@rolldown/binding, yargs, concurrently). Added engines and .nvmrc so a too-old runtime fails clearly. Typechecked and production-built on all four; verified the socket binds 127.0.0.1 only, health reports amo:true, the site still boots with the CRM unconfigured, and the limiter returns 429 with Retry-After on the ninth request. Co-Authored-By: Claude Opus 5 --- fitnes/.env.example | 2 ++ fitnes/.nvmrc | 1 + fitnes/index.html | 2 +- fitnes/package.json | 3 +++ fitnes/server/src/config.ts | 3 +++ fitnes/server/src/index.ts | 7 +++++-- hotel/.env.example | 3 ++- hotel/.nvmrc | 1 + hotel/index.html | 2 +- hotel/package.json | 3 +++ hotel/server/src/config.ts | 3 +++ hotel/server/src/index.ts | 7 +++++-- medcenter/.env.example | 4 +++- medcenter/.nvmrc | 1 + medcenter/index.html | 2 +- medcenter/package.json | 3 +++ medcenter/server/src/config.ts | 5 ++++- medcenter/server/src/index.ts | 7 +++++-- medcenter/vite.config.ts | 2 +- medcenterpersonal/.env.example | 5 +++-- medcenterpersonal/.nvmrc | 1 + medcenterpersonal/index.html | 2 +- medcenterpersonal/package.json | 3 +++ medcenterpersonal/server/src/config.ts | 5 ++++- medcenterpersonal/server/src/index.ts | 7 +++++-- medcenterpersonal/vite.config.ts | 2 +- 26 files changed, 66 insertions(+), 20 deletions(-) create mode 100644 fitnes/.nvmrc create mode 100644 hotel/.nvmrc create mode 100644 medcenter/.nvmrc create mode 100644 medcenterpersonal/.nvmrc diff --git a/fitnes/.env.example b/fitnes/.env.example index 1cc82d5..8d538d5 100644 --- a/fitnes/.env.example +++ b/fitnes/.env.example @@ -10,6 +10,8 @@ VITE_BASE_PATH=/ # --------------------------------------------------------------------------- # Server # --------------------------------------------------------------------------- +# One port per landing so all four can run side by side: +# fitnes 3000, hotel 3001, medcenter 3002, medcenterpersonal 3003. PORT=3000 # --------------------------------------------------------------------------- diff --git a/fitnes/.nvmrc b/fitnes/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/fitnes/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/fitnes/index.html b/fitnes/index.html index 7c9169f..1815920 100644 --- a/fitnes/index.html +++ b/fitnes/index.html @@ -33,7 +33,7 @@ "provider": { "@type": "Organization", "name": "Экзо Групп" }, "serviceType": "Проектирование и запуск зоны восстановления в фитнес-клубе", "areaServed": "RU", - "url": "https://exodevices.ru/fitness" + "url": "%VITE_SITE_URL%" } diff --git a/fitnes/package.json b/fitnes/package.json index 52a7217..1c6b8d6 100644 --- a/fitnes/package.json +++ b/fitnes/package.json @@ -3,6 +3,9 @@ "private": true, "version": "1.0.0", "type": "module", + "engines": { + "node": ">=22.12" + }, "scripts": { "dev": "concurrently -n client,server -c cyan,magenta \"vite\" \"tsx watch server/src/index.ts\"", "dev:client": "vite", diff --git a/fitnes/server/src/config.ts b/fitnes/server/src/config.ts index 0bc47cc..c92c7e3 100644 --- a/fitnes/server/src/config.ts +++ b/fitnes/server/src/config.ts @@ -54,5 +54,8 @@ export function readAmoConfig(): AmoConfig | null { export const serverConfig = { port: optionalNumber('PORT') ?? 3000, + // Loopback by default: in production nginx is the only thing that should + // reach this process, so the port is never exposed to the network. + host: optional('HOST') ?? '127.0.0.1', isProduction: process.env.NODE_ENV === 'production', } diff --git a/fitnes/server/src/index.ts b/fitnes/server/src/index.ts index 208e5d5..dc3825e 100644 --- a/fitnes/server/src/index.ts +++ b/fitnes/server/src/index.ts @@ -20,7 +20,10 @@ if (!leadService) { } const app = express() -app.set('trust proxy', true) +// 'loopback', not true: only the local nginx hop is trusted, so req.ip is the +// address nginx actually observed and the rate limiter below cannot be +// side-stepped with a forged X-Forwarded-For header. +app.set('trust proxy', 'loopback') app.use(express.json({ limit: '64kb' })) const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 }) @@ -103,7 +106,7 @@ if (serverConfig.isProduction) { }) } -app.listen(serverConfig.port, () => { +app.listen(serverConfig.port, serverConfig.host, () => { console.info( '[server] listening on http://localhost:%d%s', serverConfig.port, diff --git a/hotel/.env.example b/hotel/.env.example index 54ef4fd..d288f2b 100644 --- a/hotel/.env.example +++ b/hotel/.env.example @@ -10,7 +10,8 @@ VITE_BASE_PATH=/ # --------------------------------------------------------------------------- # Server # --------------------------------------------------------------------------- -# 3001 by default so this landing can run next to the fitness one (3000). +# One port per landing so all four can run side by side: +# fitnes 3000, hotel 3001, medcenter 3002, medcenterpersonal 3003. PORT=3001 # --------------------------------------------------------------------------- diff --git a/hotel/.nvmrc b/hotel/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/hotel/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/hotel/index.html b/hotel/index.html index 1c91990..c00b1c6 100644 --- a/hotel/index.html +++ b/hotel/index.html @@ -37,7 +37,7 @@ "provider": { "@type": "Organization", "name": "Экзо Групп" }, "serviceType": "Проектирование и запуск кабинета восстановления в отеле", "areaServed": "RU", - "url": "https://exodevices.ru/hotels" + "url": "%VITE_SITE_URL%" } diff --git a/hotel/package.json b/hotel/package.json index dcca9ed..e652ce1 100644 --- a/hotel/package.json +++ b/hotel/package.json @@ -3,6 +3,9 @@ "private": true, "version": "1.0.0", "type": "module", + "engines": { + "node": ">=22.12" + }, "scripts": { "dev": "concurrently -n client,server -c cyan,magenta \"vite\" \"tsx watch server/src/index.ts\"", "dev:client": "vite", diff --git a/hotel/server/src/config.ts b/hotel/server/src/config.ts index 9c56723..fe161a7 100644 --- a/hotel/server/src/config.ts +++ b/hotel/server/src/config.ts @@ -54,5 +54,8 @@ export function readAmoConfig(): AmoConfig | null { export const serverConfig = { port: optionalNumber('PORT') ?? 3001, + // Loopback by default: in production nginx is the only thing that should + // reach this process, so the port is never exposed to the network. + host: optional('HOST') ?? '127.0.0.1', isProduction: process.env.NODE_ENV === 'production', } diff --git a/hotel/server/src/index.ts b/hotel/server/src/index.ts index 7b44874..aa8e86d 100644 --- a/hotel/server/src/index.ts +++ b/hotel/server/src/index.ts @@ -20,7 +20,10 @@ if (!leadService) { } const app = express() -app.set('trust proxy', true) +// 'loopback', not true: only the local nginx hop is trusted, so req.ip is the +// address nginx actually observed and the rate limiter below cannot be +// side-stepped with a forged X-Forwarded-For header. +app.set('trust proxy', 'loopback') app.use(express.json({ limit: '64kb' })) const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 }) @@ -103,7 +106,7 @@ if (serverConfig.isProduction) { }) } -app.listen(serverConfig.port, () => { +app.listen(serverConfig.port, serverConfig.host, () => { console.info( '[server] listening on http://localhost:%d%s', serverConfig.port, diff --git a/medcenter/.env.example b/medcenter/.env.example index 98d2b2d..34af6c5 100644 --- a/medcenter/.env.example +++ b/medcenter/.env.example @@ -11,7 +11,9 @@ VITE_BASE_PATH=/ # --------------------------------------------------------------------------- # Server # --------------------------------------------------------------------------- -PORT=3000 +# One port per landing so all four can run side by side: +# fitnes 3000, hotel 3001, medcenter 3002, medcenterpersonal 3003. +PORT=3002 # --------------------------------------------------------------------------- # amoCRM — server-side only, never exposed to the browser diff --git a/medcenter/.nvmrc b/medcenter/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/medcenter/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/medcenter/index.html b/medcenter/index.html index f7bf089..35e03c8 100644 --- a/medcenter/index.html +++ b/medcenter/index.html @@ -36,7 +36,7 @@ "provider": { "@type": "Organization", "name": "Экзо Групп" }, "serviceType": "Аудит и модернизация кабинета физиотерапии медицинского центра", "areaServed": "RU", - "url": "https://exodevices.ru/medical-centers/with-physiotherapy" + "url": "%VITE_SITE_URL%" } diff --git a/medcenter/package.json b/medcenter/package.json index 7c8ca70..a65b4b8 100644 --- a/medcenter/package.json +++ b/medcenter/package.json @@ -3,6 +3,9 @@ "private": true, "version": "1.0.0", "type": "module", + "engines": { + "node": ">=22.12" + }, "scripts": { "dev": "concurrently -n client,server -c cyan,magenta \"vite\" \"tsx watch server/src/index.ts\"", "dev:client": "vite", diff --git a/medcenter/server/src/config.ts b/medcenter/server/src/config.ts index 0bc47cc..9796bc1 100644 --- a/medcenter/server/src/config.ts +++ b/medcenter/server/src/config.ts @@ -53,6 +53,9 @@ export function readAmoConfig(): AmoConfig | null { } export const serverConfig = { - port: optionalNumber('PORT') ?? 3000, + port: optionalNumber('PORT') ?? 3002, + // Loopback by default: in production nginx is the only thing that should + // reach this process, so the port is never exposed to the network. + host: optional('HOST') ?? '127.0.0.1', isProduction: process.env.NODE_ENV === 'production', } diff --git a/medcenter/server/src/index.ts b/medcenter/server/src/index.ts index c12c929..3dc19b7 100644 --- a/medcenter/server/src/index.ts +++ b/medcenter/server/src/index.ts @@ -20,7 +20,10 @@ if (!leadService) { } const app = express() -app.set('trust proxy', true) +// 'loopback', not true: only the local nginx hop is trusted, so req.ip is the +// address nginx actually observed and the rate limiter below cannot be +// side-stepped with a forged X-Forwarded-For header. +app.set('trust proxy', 'loopback') app.use(express.json({ limit: '64kb' })) const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 }) @@ -103,7 +106,7 @@ if (serverConfig.isProduction) { }) } -app.listen(serverConfig.port, () => { +app.listen(serverConfig.port, serverConfig.host, () => { console.info( '[server] listening on http://localhost:%d%s', serverConfig.port, diff --git a/medcenter/vite.config.ts b/medcenter/vite.config.ts index 73ce968..3fe7db8 100644 --- a/medcenter/vite.config.ts +++ b/medcenter/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ server: { port: 5173, proxy: { - '/api': { target: 'http://localhost:3000', changeOrigin: true }, + '/api': { target: 'http://localhost:3002', changeOrigin: true }, }, }, }) diff --git a/medcenterpersonal/.env.example b/medcenterpersonal/.env.example index 2e0bd93..75d7eaf 100644 --- a/medcenterpersonal/.env.example +++ b/medcenterpersonal/.env.example @@ -11,8 +11,9 @@ VITE_BASE_PATH=/ # --------------------------------------------------------------------------- # Server # --------------------------------------------------------------------------- -# 3000 and 3001 belong to the fitnes/medcenter and hotel landings. -PORT=3002 +# One port per landing so all four can run side by side: +# fitnes 3000, hotel 3001, medcenter 3002, medcenterpersonal 3003. +PORT=3003 # --------------------------------------------------------------------------- # amoCRM — server-side only, never exposed to the browser diff --git a/medcenterpersonal/.nvmrc b/medcenterpersonal/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/medcenterpersonal/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/medcenterpersonal/index.html b/medcenterpersonal/index.html index 6502d2e..04a7eef 100644 --- a/medcenterpersonal/index.html +++ b/medcenterpersonal/index.html @@ -36,7 +36,7 @@ "provider": { "@type": "Organization", "name": "Экзо Групп" }, "serviceType": "Запуск направления физиотерапии в медицинском центре под ключ", "areaServed": "RU", - "url": "https://exodevices.ru/medical-centers/no-physiotherapy" + "url": "%VITE_SITE_URL%" } diff --git a/medcenterpersonal/package.json b/medcenterpersonal/package.json index b448e7b..a80b708 100644 --- a/medcenterpersonal/package.json +++ b/medcenterpersonal/package.json @@ -3,6 +3,9 @@ "private": true, "version": "1.0.0", "type": "module", + "engines": { + "node": ">=22.12" + }, "scripts": { "dev": "concurrently -n client,server -c cyan,magenta \"vite\" \"tsx watch server/src/index.ts\"", "dev:client": "vite", diff --git a/medcenterpersonal/server/src/config.ts b/medcenterpersonal/server/src/config.ts index 0bc47cc..7d8bd91 100644 --- a/medcenterpersonal/server/src/config.ts +++ b/medcenterpersonal/server/src/config.ts @@ -53,6 +53,9 @@ export function readAmoConfig(): AmoConfig | null { } export const serverConfig = { - port: optionalNumber('PORT') ?? 3000, + port: optionalNumber('PORT') ?? 3003, + // Loopback by default: in production nginx is the only thing that should + // reach this process, so the port is never exposed to the network. + host: optional('HOST') ?? '127.0.0.1', isProduction: process.env.NODE_ENV === 'production', } diff --git a/medcenterpersonal/server/src/index.ts b/medcenterpersonal/server/src/index.ts index 0f42a89..53655fa 100644 --- a/medcenterpersonal/server/src/index.ts +++ b/medcenterpersonal/server/src/index.ts @@ -20,7 +20,10 @@ if (!leadService) { } const app = express() -app.set('trust proxy', true) +// 'loopback', not true: only the local nginx hop is trusted, so req.ip is the +// address nginx actually observed and the rate limiter below cannot be +// side-stepped with a forged X-Forwarded-For header. +app.set('trust proxy', 'loopback') app.use(express.json({ limit: '64kb' })) const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 }) @@ -103,7 +106,7 @@ if (serverConfig.isProduction) { }) } -app.listen(serverConfig.port, () => { +app.listen(serverConfig.port, serverConfig.host, () => { console.info( '[server] listening on http://localhost:%d%s', serverConfig.port, diff --git a/medcenterpersonal/vite.config.ts b/medcenterpersonal/vite.config.ts index eb326c6..4c69a19 100644 --- a/medcenterpersonal/vite.config.ts +++ b/medcenterpersonal/vite.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ // landings, so all of them can run side by side. port: 5175, proxy: { - '/api': { target: 'http://localhost:3002', changeOrigin: true }, + '/api': { target: 'http://localhost:3003', changeOrigin: true }, }, }, })