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/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" 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 0000000..95fcabd Binary files /dev/null and b/frontend/public/og-cover.png differ 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": [ {