From 17d04f67e616afa4975e4285d167223024100598 Mon Sep 17 00:00:00 2001 From: AxelleDev <139721736+AxelleDev@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:03:49 +0200 Subject: [PATCH 1/2] prerendered the public routes, versioned the api, harmonized collations and added an og cover --- .gitignore | 1 + backend/API.md | 7 + .../migrations/024_harmonize_collations.sql | 57 +++++++ backend/src/app.js | 12 ++ backend/src/docs/openapi.js | 5 +- backend/src/docs/paths/projects.js | 15 +- backend/tests/unit/app.test.js | 17 +++ backend/tests/unit/openapiSync.test.js | 10 +- frontend/.prettierignore | 1 + frontend/eslint.config.js | 2 +- frontend/index.html | 4 +- frontend/package.json | 2 +- frontend/public/og-cover.png | Bin 0 -> 48478 bytes frontend/scripts/prerender.mjs | 68 +++++++++ frontend/src/App.jsx | 144 ++++++++++-------- frontend/src/components/Seo.jsx | 7 +- frontend/src/components/ThemeToggle.jsx | 17 ++- frontend/src/context/ToastContext.jsx | 95 ++++++------ frontend/src/entry-server.jsx | 21 +++ frontend/src/main.jsx | 21 ++- frontend/vercel.json | 2 +- 21 files changed, 382 insertions(+), 126 deletions(-) create mode 100644 backend/migrations/024_harmonize_collations.sql create mode 100644 frontend/public/og-cover.png create mode 100644 frontend/scripts/prerender.mjs create mode 100644 frontend/src/entry-server.jsx diff --git a/.gitignore b/.gitignore index afea2f4..5dd107d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ playwright-report/ # Browser-tooling session artifacts (screenshots/snapshots from MCP-driven runs) .playwright-mcp/ +dist-ssr diff --git a/backend/API.md b/backend/API.md index d4344c2..aa81a06 100644 --- a/backend/API.md +++ b/backend/API.md @@ -11,6 +11,13 @@ human-readable summary; the spec is the machine-readable source of truth. ## Conventions +### Versioning + +Every `/api/*` endpoint below is also mounted, identically, under `/api/v1/*`. +The unversioned prefix is the canonical one used by the frontend; external +integrations should pin `/api/v1` so a future breaking change (shipped as +`/api/v2`) never affects them. + ### Authentication The session is carried by **HttpOnly cookies** set on login (`frameset_access_token`, diff --git a/backend/migrations/024_harmonize_collations.sql b/backend/migrations/024_harmonize_collations.sql new file mode 100644 index 0000000..04736e8 --- /dev/null +++ b/backend/migrations/024_harmonize_collations.sql @@ -0,0 +1,57 @@ +-- Harmonizes text collations on utf8mb4_unicode_ci across the schema. +-- +-- 001_init created `users` (and later 023 `user_recovery_codes`) as +-- utf8mb4_unicode_ci but the project tables as utf8mb4_general_ci — two +-- different accent/case comparison rules living side by side. Nothing +-- compares text across those tables today, which is exactly why now is the +-- cheap moment to converge: one collation, one sorting behavior, no surprise +-- the day a cross-table comparison or UNION appears. +-- +-- Deliberately NOT touched: `revoked_tokens`, whose `token` column is +-- intentionally ascii_bin (lowercase hex digests — see 021); CONVERT TO +-- CHARACTER SET rewrites every text column of a table and would clobber it. +-- +-- Same idempotency style as 021: guarded via information_schema, so re-runs +-- (and already-converted databases) are no-ops. CONVERT TO also rebuilds the +-- indexes on the converted columns; these tables are small (per-user content, +-- 50-color palette cap), so the rewrite is instantaneous at this scale. + +SET @collation := ( + SELECT TABLE_COLLATION FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'projects' +); +SET @sql := IF(@collation <> 'utf8mb4_unicode_ci', + 'ALTER TABLE `projects` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @collation := ( + SELECT TABLE_COLLATION FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project_brush_norms' +); +SET @sql := IF(@collation <> 'utf8mb4_unicode_ci', + 'ALTER TABLE `project_brush_norms` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @collation := ( + SELECT TABLE_COLLATION FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project_typography_norms' +); +SET @sql := IF(@collation <> 'utf8mb4_unicode_ci', + 'ALTER TABLE `project_typography_norms` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @collation := ( + SELECT TABLE_COLLATION FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project_palette' +); +SET @sql := IF(@collation <> 'utf8mb4_unicode_ci', + 'ALTER TABLE `project_palette` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/backend/src/app.js b/backend/src/app.js index 7edff42..ee79d7a 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -243,6 +243,18 @@ app.use('/api/fonts', fontsRoutes); // exemption applies; rate limited inside the router). app.use('/api/share', shareRoutes); +// Versioned alias: the exact same routers, additionally reachable under +// /api/v1/* so an external integration can pin a version from day one. The +// unversioned /api remains the canonical path used by the SPA; a future +// breaking change would fork new routers under /api/v2 while these keep +// serving v1 clients unchanged. The CSRF middlewares above are mounted on +// '/api' and therefore already cover this prefix. +app.use('/api/v1/auth', authRoutes); +app.use('/api/v1/users', userRoutes); +app.use('/api/v1/projects', projectsRoutes); +app.use('/api/v1/fonts', fontsRoutes); +app.use('/api/v1/share', shareRoutes); + // E2E test mode only: lets a Playwright run read a verification code without // a real inbox. Never mounted otherwise — see utils/testMode.js. if (isE2ETestMode) { diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index ef326b0..5e8a644 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -26,7 +26,10 @@ const openapiSpec = { '(`POST`/`PUT`/`PATCH`/`DELETE`) use the double-submit CSRF pattern: send the ' + '`frameset_csrf_token` cookie value back in the `x-csrf-token` header ' + '(fetch one via `GET /api/auth/csrf-token`). The JSON body is capped at 10 kB ' + - 'and sensitive endpoints are rate limited.', + 'and sensitive endpoints are rate limited.\n\n' + + '**Versioning**: every `/api/*` path documented here is also mounted, identically, ' + + 'under `/api/v1/*` — pin the versioned prefix for external integrations; a breaking ' + + 'change would ship as `/api/v2` while `/api/v1` keeps serving existing clients.', contact: { name: 'Axelle Tempier', email: 'axelle.tempier@gmail.com' }, }, servers: [{ url: '/', description: 'Same origin as the app' }], diff --git a/backend/src/docs/paths/projects.js b/backend/src/docs/paths/projects.js index fe30553..9c88cf8 100644 --- a/backend/src/docs/paths/projects.js +++ b/backend/src/docs/paths/projects.js @@ -436,9 +436,18 @@ module.exports = { properties: { name: { type: 'string' }, ownerName: { type: 'string' }, - brushNorms: { type: 'array', items: { type: 'object' } }, - typographyNorms: { type: 'array', items: { type: 'object' } }, - palette: { type: 'array', items: { type: 'object' } }, + brushNorms: { + type: 'array', + items: { $ref: '#/components/schemas/BrushNorm' }, + }, + typographyNorms: { + type: 'array', + items: { $ref: '#/components/schemas/TypographyNorm' }, + }, + palette: { + type: 'array', + items: { $ref: '#/components/schemas/PaletteColor' }, + }, }, }, }, diff --git a/backend/tests/unit/app.test.js b/backend/tests/unit/app.test.js index 274f754..67fd728 100644 --- a/backend/tests/unit/app.test.js +++ b/backend/tests/unit/app.test.js @@ -46,6 +46,23 @@ describe('application middleware', () => { }); }); + describe('versioned API alias', () => { + it('serves the same surface under /api/v1, CSRF protection included', async () => { + // Same endpoint, both prefixes. + const unversioned = await request(app).get('/api/auth/csrf-token'); + const versioned = await request(app).get('/api/v1/auth/csrf-token'); + expect(unversioned.status).toBe(200); + expect(versioned.status).toBe(200); + expect(versioned.body).toEqual(expect.objectContaining({ csrfToken: expect.any(String) })); + + // The '/api'-mounted CSRF guard covers the alias too: an unprotected + // mutation is rejected exactly like on the canonical prefix. + const rejected = await request(app).post('/api/v1/auth/logout'); + expect(rejected.status).toBe(403); + expect(rejected.body.error).toMatch(/csrf/i); + }); + }); + describe('security headers', () => { it('exposes an explicit Content-Security-Policy header', async () => { const res = await request(app).get('/api/auth/csrf-token'); diff --git a/backend/tests/unit/openapiSync.test.js b/backend/tests/unit/openapiSync.test.js index 9d21b4c..9f96394 100644 --- a/backend/tests/unit/openapiSync.test.js +++ b/backend/tests/unit/openapiSync.test.js @@ -54,11 +54,17 @@ describe('OpenAPI spec stays in sync with the mounted routes', () => { .map(normalizeRoutePath) // The comparison covers the documented surface: /health and /api/*. // Swagger's own endpoints (/api-docs*) and the E2E-only /api/_test - // routes (not mounted here) are intentionally undocumented. + // routes (not mounted here) are intentionally undocumented. /api/v1/* + // is a mount-level ALIAS of the same routers (see app.js): its surface + // is identical by construction, so it is documented once via the spec's + // servers list rather than duplicating every path entry. .filter( (route) => / \/health$/.test(route) || - (/ \/api\//.test(route) && !/ \/api-docs/.test(route) && !/ \/api\/_test/.test(route)), + (/ \/api\//.test(route) && + !/ \/api-docs/.test(route) && + !/ \/api\/_test/.test(route) && + !/ \/api\/v1\//.test(route)), ); const documented = Object.entries(openapiSpec.paths).flatMap(([specPath, operations]) => diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 6715024..876c211 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -5,3 +5,4 @@ package-lock.json public test-results playwright-report +dist-ssr diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index e3c42d0..2855e60 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -20,7 +20,7 @@ const vitestGlobals = { export default [ // Build output, dependencies and coverage reports are never linted. - { ignores: ['dist', 'node_modules', 'coverage'] }, + { ignores: ['dist', 'dist-ssr', 'node_modules', 'coverage'] }, // Application and test source: browser environment, React component rules. { diff --git a/frontend/index.html b/frontend/index.html index e1805dc..74b054c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -29,7 +29,7 @@ content="FrameSet keeps every project's graphic standards and color palette in one place." /> - + @@ -38,7 +38,7 @@ name="twitter:description" content="FrameSet keeps every project's graphic standards and color palette in one place." /> - + diff --git a/frontend/package.json b/frontend/package.json index 4111ec7..4085a58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ }, "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.mjs", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", diff --git a/frontend/public/og-cover.png b/frontend/public/og-cover.png new file mode 100644 index 0000000000000000000000000000000000000000..95fcabd55e428b9152216694b9c8253428d1ad23 GIT binary patch literal 48478 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 literal 0 HcmV?d00001 diff --git a/frontend/scripts/prerender.mjs b/frontend/scripts/prerender.mjs new file mode 100644 index 0000000..4eeb5f7 --- /dev/null +++ b/frontend/scripts/prerender.mjs @@ -0,0 +1,68 @@ +/** + * Build-time prerendering of the public, auth-free routes: after the client + * build, the SSR bundle (src/entry-server.jsx, built to dist-ssr/) renders + * each route to HTML which is injected into dist/index.html's empty #root and + * written as /index.html. Vercel serves matching static files before + * the SPA rewrite kicks in, so first paint no longer waits for React to boot + * — the app then hydrates in place (see src/main.jsx). + * + * Only routes listed here may be prerendered, and their page components MUST + * be imported eagerly in App.jsx (renderToString cannot await a lazy chunk). + * Auth-gated routes (login/register) are deliberately absent: their guard + * renders a loading state until the session probe settles, so their prerender + * would be an empty spinner. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ROUTES = ['/', '/privacy', '/terms']; +const ROOT_MARKER = '
'; + +const { render } = await import(pathToFileURL(path.join(root, 'dist-ssr', 'entry-server.js')).href); +const template = fs.readFileSync(path.join(root, 'dist', 'index.html'), 'utf8'); +if (!template.includes(ROOT_MARKER)) { + throw new Error('prerender: dist/index.html no longer contains an empty #root to inject into.'); +} + +// The EMPTY shell must keep existing for every non-prerendered route: the SPA +// fallback rewrite points at /app-shell.html (see vercel.json), because +// index.html itself becomes the prerendered landing below. Serving the +// landing's HTML to, say, /login would make hydration mismatch on purpose. +fs.writeFileSync(path.join(root, 'dist', 'app-shell.html'), template); + +// Prerendered pages also get the WHOLE stylesheet inlined in place of the +// render-blocking : first paint then needs nothing beyond the HTML +// itself. Inlining everything (not "critical CSS" extraction) is deliberate — +// every rule is present, so dark mode and every breakpoint render exactly +// right with zero flash; at ~40KB (a few KB gzipped) the weight is cheap. +// The shell keeps the plain , so the rest of the app is untouched. +const linkMatch = template.match(/]*href="([^"]+\.css)"[^>]*>/); +if (!linkMatch) { + throw new Error('prerender: could not find the stylesheet in dist/index.html.'); +} +const css = fs.readFileSync(path.join(root, 'dist', linkMatch[1].replace(/^\//, '')), 'utf8'); +const inlineCss = (page) => page.replace(linkMatch[0], ``); + +for (const route of ROUTES) { + const html = render(route); + if (!html || html.length < 500) { + throw new Error(`prerender: suspiciously small render for ${route} (${html.length} chars).`); + } + // The route is stamped on the container so main.jsx only hydrates when the + // prerendered HTML actually belongs to the URL being served (a misrouted + // fallback falls back to a clean client render instead of a mismatch). + const page = inlineCss( + template.replace(ROOT_MARKER, `
${html}
`), + ); + const outFile = + route === '/' + ? path.join(root, 'dist', 'index.html') + : path.join(root, 'dist', route.slice(1), 'index.html'); + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, page); + console.log( + `prerendered ${route} -> ${path.relative(root, outFile)} (${Math.round(page.length / 1024)}KB)`, + ); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cc949ce..3a8250a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,9 +15,17 @@ import CursorDot from './components/CursorDot'; import { captureException } from './utils/monitoring'; import MainLayout from './layouts/MainLayout'; -// Pages are code-split via React.lazy so each route's JS (and heavy deps like -// jsPDF / react-select) is only downloaded when that route is first visited. -const Landing = lazy(() => import('./pages/Landing')); +// Prerendered routes (see scripts/prerender.mjs) are imported EAGERLY: the +// server-side renderToString can't await a lazy chunk, and an eager import +// also guarantees hydration never suspends on them. They are light, +// markup-heavy pages, so the main bundle barely grows. +import Landing from './pages/Landing'; +import Terms from './pages/Terms'; +import Privacy from './pages/Privacy'; + +// Every other page stays code-split via React.lazy so each route's JS (and +// heavy deps like jsPDF / react-select) is only downloaded when that route is +// first visited. const Login = lazy(() => import('./pages/Login')); const Register = lazy(() => import('./pages/Register')); const ForgotPassword = lazy(() => import('./pages/ForgotPassword')); @@ -27,8 +35,6 @@ const ProjectPalette = lazy(() => import('./pages/ProjectPalette')); const ProjectExport = lazy(() => import('./pages/ProjectExport')); const Profile = lazy(() => import('./pages/Profile')); const Verify = lazy(() => import('./pages/Verify')); -const Terms = lazy(() => import('./pages/Terms')); -const Privacy = lazy(() => import('./pages/Privacy')); const SharedProject = lazy(() => import('./pages/SharedProject')); const NotFound = lazy(() => import('./pages/NotFound')); @@ -38,7 +44,6 @@ const NotFound = lazy(() => import('./pages/NotFound')); function prefetchRouteChunks() { const prefetch = () => { [ - import('./pages/Landing'), import('./pages/Login'), import('./pages/Register'), import('./pages/ForgotPassword'), @@ -138,80 +143,97 @@ function AppRoutes() { prefetchRouteChunks(); }, []); + return ( + + + + ); +} + +// The routed UI without any router: shared verbatim by the browser entry +// (BrowserRouter, above) and the build-time prerenderer (StaticRouter, see +// src/entry-server.jsx), so the prerendered HTML and the hydrating client +// render exactly the same tree. +export function AppRouteTree() { return ( <> {/* Site-wide decorative cursor follower (self-disables for touch and reduced-motion users — see the component). */} - - - }> - - } /> - - - - } - /> - - - - } - /> + + }> + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + } /> + } /> + }> + } /> + } /> - - + + + } - /> - } /> - } /> - } /> - } /> - }> - } /> - } /> - - - - } - > - } /> - } /> - } /> - } /> - + > + } /> + } /> + } /> + } /> - } /> - - - +
+ } /> + + ); } -// Establishes the provider hierarchy (auth first, then projects). -export default function App() { +// Establishes the provider hierarchy (auth first, then projects). Exported so +// the build-time prerenderer wraps the exact same providers around the tree. +export function AppProviders({ children }) { return ( - - - + {children} ); } + +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/components/Seo.jsx b/frontend/src/components/Seo.jsx index 23927d4..3f4c508 100644 --- a/frontend/src/components/Seo.jsx +++ b/frontend/src/components/Seo.jsx @@ -15,9 +15,10 @@ const SITE_URL = (import.meta.env.VITE_SITE_URL || 'https://frameset-taupe.verce ); const DEFAULT_DESCRIPTION = "FrameSet keeps every project's graphic standards and color palette in one place."; -// Brand logo as a safe default that always exists; swap for a dedicated -// 1200×630 `og-cover.png` for richer link previews. -const DEFAULT_IMAGE = `${SITE_URL}/FrameSet_Logo.png`; +// Dedicated 1200×630 cover (brand lockup + tagline + palette strip) so links +// to any page unfurl with a proper card; shared projects override this with +// their own server-rendered preview. +const DEFAULT_IMAGE = `${SITE_URL}/og-cover.png`; export default function Seo({ title, diff --git a/frontend/src/components/ThemeToggle.jsx b/frontend/src/components/ThemeToggle.jsx index 1addfc9..4d6d279 100644 --- a/frontend/src/components/ThemeToggle.jsx +++ b/frontend/src/components/ThemeToggle.jsx @@ -1,21 +1,30 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import useTheme from '../hooks/useTheme'; // Button that switches between light and dark themes (sun/moon icon). export default function ThemeToggle({ className = '' }) { const { theme, toggleTheme } = useTheme(); + // The icon depends on localStorage, which build-time prerendering can't + // know: render it only once mounted so the server HTML and the client's + // first render agree (the button keeps its size, so nothing shifts). + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); const isDark = theme === 'dark'; return ( - - ); - })} - , - document.body, - )} + {toast.message} + + + ); + })} + , + document.body, + )} ); } diff --git a/frontend/src/entry-server.jsx b/frontend/src/entry-server.jsx new file mode 100644 index 0000000..828439a --- /dev/null +++ b/frontend/src/entry-server.jsx @@ -0,0 +1,21 @@ +/** + * Build-time prerender entry (see scripts/prerender.mjs): renders a public + * route to an HTML string with the exact same providers + route tree as the + * browser entry, StaticRouter standing in for BrowserRouter. Only routes + * whose page components are imported eagerly in App.jsx can be rendered here + * (renderToString cannot await a lazy chunk). + */ +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { StaticRouter } from 'react-router'; +import { AppProviders, AppRouteTree } from './App'; + +export function render(url) { + return renderToString( + + + + + , + ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index bb2e6aa..54f2ea7 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,6 +1,6 @@ // Entry point: mount into #root. StrictMode surfaces dev-only problems. import React from 'react'; -import { createRoot } from 'react-dom/client'; +import { createRoot, hydrateRoot } from 'react-dom/client'; import { registerSW } from 'virtual:pwa-register'; import App from './App'; import { initMonitoring } from './utils/monitoring'; @@ -14,9 +14,22 @@ initMonitoring(); registerSW({ immediate: true }); const container = document.getElementById('root'); -const root = createRoot(container); -root.render( +const app = ( - , + ); + +// Public routes are prerendered at build time (scripts/prerender.mjs): when +// #root carries the server HTML FOR THIS URL, hydrate it in place. Any other +// case — empty shell, or a misconfigured fallback serving a prerendered page +// for the wrong route — mounts from a clean container instead, so hydration +// can never mismatch on purpose. +const prerenderedRoute = container.dataset.prerendered; +const currentRoute = window.location.pathname.replace(/\/+$/, '') || '/'; +if (container.hasChildNodes() && prerenderedRoute === currentRoute) { + hydrateRoot(container, app); +} else { + if (container.hasChildNodes()) container.replaceChildren(); + createRoot(container).render(app); +} diff --git a/frontend/vercel.json b/frontend/vercel.json index bbe4deb..12e6e24 100644 --- a/frontend/vercel.json +++ b/frontend/vercel.json @@ -16,7 +16,7 @@ "source": "/api/:path*", "destination": "https://frameset-production.up.railway.app/api/:path*" }, - { "source": "/(.*)", "destination": "/index.html" } + { "source": "/(.*)", "destination": "/app-shell.html" } ], "headers": [ { From a214f8085c7d71a862e51a7b5f1e0f5534d46b79 Mon Sep 17 00:00:00 2001 From: AxelleDev <139721736+AxelleDev@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:18:04 +0200 Subject: [PATCH 2/2] bumped ip-address to a patched version for the ssrf advisories --- backend/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index c00c20e..e8d415c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -4750,9 +4750,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12"