From ad75355eed594209a1257a1c445f678877495ab1 Mon Sep 17 00:00:00 2001 From: AxelleDev <139721736+AxelleDev@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:16:49 +0200 Subject: [PATCH 1/2] added a server-rendered social preview image behind share links --- README.md | 5 +- backend/API.md | 12 +- backend/package-lock.json | 216 ++++++++++++++++++ backend/package.json | 1 + backend/src/assets/Figtree-Bold.ttf | Bin 0 -> 24764 bytes backend/src/assets/Figtree-Medium.ttf | Bin 0 -> 24616 bytes backend/src/assets/Figtree-Regular.ttf | Bin 0 -> 24672 bytes .../src/controllers/projects.controller.js | 83 +++++++ backend/src/docs/paths/projects.js | 39 ++++ backend/src/routes/share.routes.js | 3 + backend/src/services/sharePreview.service.js | 149 ++++++++++++ .../tests/unit/projects.controller.test.js | 83 +++++++ .../tests/unit/sharePreview.service.test.js | 126 ++++++++++ e2e/tests/critical-path.spec.js | 21 ++ frontend/src/pages/SharedProject.jsx | 5 +- frontend/vercel.json | 11 + 16 files changed, 747 insertions(+), 7 deletions(-) create mode 100644 backend/src/assets/Figtree-Bold.ttf create mode 100644 backend/src/assets/Figtree-Medium.ttf create mode 100644 backend/src/assets/Figtree-Regular.ttf create mode 100644 backend/src/services/sharePreview.service.js create mode 100644 backend/tests/unit/sharePreview.service.test.js diff --git a/README.md b/README.md index cfdd3f8..a817bd5 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,10 @@ from one drawing to the next. `.gpl`, Procreate `.swatches` — all generated client-side, no dependency, and re-importable the same way), or share a public read-only link (revocable anytime) that anyone can open without an account — with a - scannable, downloadable QR code for showing the sheet on a phone. + scannable, downloadable QR code for showing the sheet on a phone. Shared + links **unfurl with a live preview image** of the project's actual palette + (rendered server-side) on WhatsApp, LinkedIn, Discord, Slack and the like, + instead of a generic logo card. - **Accessible & resilient** — keyboard-operable throughout (including drag-and-drop, which always has a keyboard alternative), a warning before leaving a page with unsaved changes, a heads-up before your session expires, and clear rate-limit diff --git a/backend/API.md b/backend/API.md index 0f8fd57..4a05919 100644 --- a/backend/API.md +++ b/backend/API.md @@ -165,11 +165,13 @@ Pinned projects sort before unpinned ones on `GET /projects`. ### Sharing -| Method | Path | Auth | Success | -| -------- | --------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `POST` | `/projects/:id/share` | ✓ | `{ shareToken }` — mints (or returns the existing) public share token | -| `DELETE` | `/projects/:id/share` | ✓ | `{ success }` — revokes the link immediately | -| `GET` | `/share/:token` | – | the read-only reference sheet: `{ name, brushNorms[], typographyNorms[], palette[], ownerName }` — public, rate limited per IP (60/min); `404` if the token is invalid, revoked, or the project is trashed | +| Method | Path | Auth | Success | +| -------- | --------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/projects/:id/share` | ✓ | `{ shareToken }` — mints (or returns the existing) public share token | +| `DELETE` | `/projects/:id/share` | ✓ | `{ success }` — revokes the link immediately | +| `GET` | `/share/:token` | – | the read-only reference sheet: `{ name, brushNorms[], typographyNorms[], palette[], ownerName }` — public, rate limited per IP (60/min); `404` if the token is invalid, revoked, or the project is trashed | +| `GET` | `/share/:token/preview.png` | – | a 1200×630 PNG of the project (name, owner credit, palette swatches) rendered server-side — the og:image behind share links; same rate limit and 404 contract | +| `GET` | `/share/:token/embed` | – | minimal HTML carrying the Open Graph/Twitter tags for the share link — social crawlers are rewritten here by the frontend (they don't run the SPA); humans get redirected to the real page | `ownerName` is the owner's display name only — never their id or email — shown as a "Made by …" credit on the public page and in the exported PDF. diff --git a/backend/package-lock.json b/backend/package-lock.json index 133cfdf..c00c20e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "frameset-backend", "version": "1.0.0", "dependencies": { + "@resvg/resvg-js": "^2.6.2", "@sentry/node": "^10.68.0", "bcryptjs": "^3.0.3", "cors": "^2.8.5", @@ -1629,6 +1630,221 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", diff --git a/backend/package.json b/backend/package.json index a572850..d3c8f57 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "format:check": "prettier --check ." }, "dependencies": { + "@resvg/resvg-js": "^2.6.2", "@sentry/node": "^10.68.0", "bcryptjs": "^3.0.3", "cors": "^2.8.5", diff --git a/backend/src/assets/Figtree-Bold.ttf b/backend/src/assets/Figtree-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4393ff60f04c4b4f4fd454afa32ac586e7970eef GIT binary patch literal 24764 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 literal 0 HcmV?d00001 diff --git a/backend/src/controllers/projects.controller.js b/backend/src/controllers/projects.controller.js index 6c24711..89b9932 100644 --- a/backend/src/controllers/projects.controller.js +++ b/backend/src/controllers/projects.controller.js @@ -5,6 +5,7 @@ const { getAuthenticatedUserId, createControllerLogger } = require('../utils/auth.utils'); const projectsService = require('../services/projects.service'); +const sharePreviewService = require('../services/sharePreview.service'); const logProjectsControllerError = createControllerLogger('projects'); @@ -220,6 +221,86 @@ const getSharedProject = async (req, res) => { } }; +// PUBLIC (no auth): the social-preview PNG behind a share link's og:image. +// Same 404 contract as the JSON share read for unknown/revoked tokens. +const getSharedProjectPreview = async (req, res) => { + try { + const png = await sharePreviewService.getSharePreviewPngByToken(req.params.token); + res.set('Content-Type', 'image/png'); + // Scrapers cache aggressively anyway; a short TTL keeps repeat unfurls + // cheap without pinning a stale palette for long. + res.set('Cache-Control', 'public, max-age=600'); + res.send(png); + } catch (error) { + if (error.code === 'not_found') { + return res.status(404).json({ error: 'This link is no longer active.' }); + } + logProjectsControllerError(req, 'get_shared_preview', error); + res.status(500).json({ error: 'Server error.' }); + } +}; + +// PUBLIC (no auth): the crawler-facing HTML for a share link. Social scrapers +// don't run the SPA's JavaScript, so Vercel rewrites their requests for +// /s/:token here; this page carries the Open Graph tags (including the +// preview image above) and bounces any human who lands on it back to the SPA. +const getSharedProjectEmbed = async (req, res) => { + const escapeHtml = (value) => + String(value).replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c], + ); + + try { + const project = await projectsService.getSharedProjectByToken(req.params.token); + const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:5173'; + const pageUrl = `${frontendOrigin}/s/${encodeURIComponent(req.params.token)}`; + const imageUrl = `${frontendOrigin}/api/share/${encodeURIComponent(req.params.token)}/preview.png`; + const title = escapeHtml(`${project.name} — FrameSet`); + const description = escapeHtml( + `The graphic reference sheet for ${project.name}, by ${project.ownerName}. Colors, typography and brush specs in one place.`, + ); + + res.set('Content-Type', 'text/html; charset=utf-8'); + res.set('Cache-Control', 'public, max-age=600'); + // Keep the raw backend URL out of search indexes; the canonical below + // points crawlers at the real SPA page instead. + res.set('X-Robots-Tag', 'noindex'); + // Crawlers read the tags; the sends everyone else to the SPA. + res.send(` + + + +${title} + + + + + + + + + + + + + + + + + + + +`); + } catch (error) { + if (error.code === 'not_found') { + return res.status(404).json({ error: 'This link is no longer active.' }); + } + logProjectsControllerError(req, 'get_shared_embed', error); + res.status(500).json({ error: 'Server error.' }); + } +}; + // Rename a project owned by the user and refresh its last_edited timestamp. // Same name rule as creation, enforced by the shared service validator. const updateProjectName = async (req, res) => { @@ -672,6 +753,8 @@ module.exports = { enableSharing, disableSharing, getSharedProject, + getSharedProjectPreview, + getSharedProjectEmbed, addBrushNorm, addTypographyNorm, updatePalette, diff --git a/backend/src/docs/paths/projects.js b/backend/src/docs/paths/projects.js index 77fa5c6..5c275d7 100644 --- a/backend/src/docs/paths/projects.js +++ b/backend/src/docs/paths/projects.js @@ -449,6 +449,45 @@ module.exports = { }, }, }, + '/api/share/{token}/preview.png': { + get: { + tags: ['Projects'], + summary: 'PUBLIC: social-preview image for a share link (no auth)', + description: + 'A 1200x630 PNG of the shared project — its name, owner credit and actual palette ' + + 'swatches — rendered server-side. Used as the og:image behind share links so they ' + + 'unfurl with a real preview on social platforms. Same 404 contract as the share read.', + parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 200: { + description: 'The preview image.', + content: { 'image/png': { schema: { type: 'string', format: 'binary' } } }, + }, + 404: { $ref: '#/components/responses/NotFound' }, + 429: { $ref: '#/components/responses/RateLimited' }, + }, + }, + }, + '/api/share/{token}/embed': { + get: { + tags: ['Projects'], + summary: 'PUBLIC: crawler-facing HTML for a share link (no auth)', + description: + 'A minimal HTML document carrying the Open Graph / Twitter Card tags (including the ' + + 'preview image) for a shared project. Social scrapers do not execute the SPA, so the ' + + 'frontend rewrites their requests for /s/:token here; humans landing on it are ' + + 'redirected to the real page.', + parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 200: { + description: 'The embed HTML.', + content: { 'text/html': { schema: { type: 'string' } } }, + }, + 404: { $ref: '#/components/responses/NotFound' }, + 429: { $ref: '#/components/responses/RateLimited' }, + }, + }, + }, '/api/projects/{id}/brush-norms': { post: { tags: ['Projects'], diff --git a/backend/src/routes/share.routes.js b/backend/src/routes/share.routes.js index fda1578..f436d78 100644 --- a/backend/src/routes/share.routes.js +++ b/backend/src/routes/share.routes.js @@ -20,6 +20,9 @@ const shareViewLimiter = rateLimit({ handler: jsonLimitHandler('Too many requests, please try again in a minute.'), }); +// Specific paths first, then the bare token read. +router.get('/:token/preview.png', shareViewLimiter, projectsController.getSharedProjectPreview); +router.get('/:token/embed', shareViewLimiter, projectsController.getSharedProjectEmbed); router.get('/:token', shareViewLimiter, projectsController.getSharedProject); module.exports = router; diff --git a/backend/src/services/sharePreview.service.js b/backend/src/services/sharePreview.service.js new file mode 100644 index 0000000..e7a3adc --- /dev/null +++ b/backend/src/services/sharePreview.service.js @@ -0,0 +1,149 @@ +/** + * Social-preview image for a shared project (the og:image behind a share + * link): a 1200x630 PNG of the project's actual palette, rendered fully + * server-side. The image is composed as an SVG string and rasterized with + * resvg, with the site's Figtree faces loaded explicitly so text renders + * identically no matter what fonts the host machine has. + */ +const path = require('path'); +const fs = require('fs'); +const { Resvg } = require('@resvg/resvg-js'); +const { getSharedProjectByToken } = require('./projectSharing.service'); + +// Standard Open Graph canvas. +const WIDTH = 1200; +const HEIGHT = 630; +const MARGIN = 64; + +// Design tokens mirrored from the frontend (canvas / primary ink / periwinkle). +const CANVAS = '#F8F9FF'; +const INK = '#3C3D48'; +const BLUE = '#8994DF'; + +// One row of swatches; anything beyond the cap collapses into a "+N" tile. +const MAX_SWATCHES = 8; +const SWATCH_TOP = 236; +const SWATCH_HEIGHT = 248; +const SWATCH_GAP = 14; +const SWATCH_RADIUS = 20; + +const ASSETS_DIR = path.join(__dirname, '..', 'assets'); +const FONT_FILES = ['Figtree-Regular.ttf', 'Figtree-Medium.ttf', 'Figtree-Bold.ttf'].map((file) => + path.join(ASSETS_DIR, file), +); + +// Loaded once; the logo is embedded in the SVG as a data URI. +let logoDataUri = null; +const getLogoDataUri = () => { + if (!logoDataUri) { + const buffer = fs.readFileSync(path.join(ASSETS_DIR, 'frameset-logo.png')); + logoDataUri = `data:image/png;base64,${buffer.toString('base64')}`; + } + return logoDataUri; +}; + +const escapeXml = (value) => + String(value).replace( + /[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c], + ); + +const truncate = (value, max) => + value.length > max ? `${value.slice(0, max - 1).trimEnd()}…` : value; + +// Same near-white rule as the PDF: swatches lighter than this get a hairline +// border so they don't dissolve into the background. +const hexLuminance = (hex) => { + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255; + const g = (n >> 8) & 255; + const b = n & 255; + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +}; + +// The swatch row, flush to both margins like the PDF grid: only the gaps +// between tiles are fixed, the tiles share the remaining width equally. +const buildSwatches = (palette) => { + const shown = palette.slice(0, palette.length > MAX_SWATCHES ? MAX_SWATCHES - 1 : MAX_SWATCHES); + const overflow = palette.length - shown.length; + const tiles = overflow > 0 ? shown.length + 1 : shown.length; + const innerWidth = WIDTH - 2 * MARGIN; + const tileWidth = (innerWidth - SWATCH_GAP * (tiles - 1)) / tiles; + const labelY = SWATCH_TOP + SWATCH_HEIGHT + 42; + + const parts = []; + shown.forEach((color, index) => { + const x = MARGIN + index * (tileWidth + SWATCH_GAP); + const needsBorder = hexLuminance(color.hex) > 0.93; + parts.push( + ``, + `${escapeXml(color.hex.toUpperCase())}`, + ); + }); + + if (overflow > 0) { + const x = MARGIN + shown.length * (tileWidth + SWATCH_GAP); + parts.push( + ``, + `+${overflow}`, + ); + } + + return parts.join('\n '); +}; + +// Fallback panel when the shared project has no palette yet: a soft brand +// band so the card never looks broken or empty. +const buildEmptyPanel = () => { + const innerWidth = WIDTH - 2 * MARGIN; + return [ + ``, + `A graphic reference sheet`, + ].join('\n '); +}; + +const countLabel = (count, singular) => `${count} ${singular}${count === 1 ? '' : 's'}`; + +const buildSharePreviewSvg = ({ name, ownerName, palette, brushNorms, typographyNorms }) => { + const specsCount = brushNorms.length + typographyNorms.length; + const metaParts = [`by ${truncate(ownerName, 30)}`]; + if (palette.length > 0) metaParts.push(countLabel(palette.length, 'color')); + if (specsCount > 0) metaParts.push(countLabel(specsCount, 'spec')); + + // The logo keeps its native 2244x1148 ratio; sized and placed so it clears + // the hex labels above it. + const logoHeight = 56; + const logoWidth = Math.round(logoHeight * (2244 / 1148)); + + return ` + + ${escapeXml(truncate(name, 28))} + ${escapeXml(metaParts.join(' · '))} + ${palette.length > 0 ? buildSwatches(palette) : buildEmptyPanel()} + + Made with FrameSet — the graphic reference for your projects. +`; +}; + +const renderSvgToPng = (svg) => { + const resvg = new Resvg(svg, { + font: { + fontFiles: FONT_FILES, + defaultFontFamily: 'Figtree', + loadSystemFonts: false, + }, + }); + return Buffer.from(resvg.render().asPng()); +}; + +// Token in, PNG out. Propagates the share lookup's 'not_found' untouched so +// the controller maps revoked/unknown tokens to a 404 exactly like the JSON +// share endpoint does. +const getSharePreviewPngByToken = async (token) => { + const project = await getSharedProjectByToken(token); + return renderSvgToPng(buildSharePreviewSvg(project)); +}; + +module.exports = { buildSharePreviewSvg, getSharePreviewPngByToken }; diff --git a/backend/tests/unit/projects.controller.test.js b/backend/tests/unit/projects.controller.test.js index c03198b..fa32ff1 100644 --- a/backend/tests/unit/projects.controller.test.js +++ b/backend/tests/unit/projects.controller.test.js @@ -863,6 +863,89 @@ describe('projects controller', () => { }); }); + describe('shared project social preview (public)', () => { + const token = 'a'.repeat(32); + const mockSharedProjectQueries = () => { + db.query + .mockResolvedValueOnce([[{ id: 7, name: 'Neo-Tokyo', owner_name: 'Axelle' }]]) + .mockResolvedValueOnce([[]]) // brush norms + .mockResolvedValueOnce([[]]) // typography norms + .mockResolvedValueOnce([[{ id: 9, name: 'Ink', hex: '#112233' }]]); + }; + + it('serves the preview as a cacheable PNG', async () => { + mockSharedProjectQueries(); + const req = { params: { token } }; + const res = { + set: jest.fn(), + send: jest.fn(), + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + await projectsController.getSharedProjectPreview(req, res); + + expect(res.set).toHaveBeenCalledWith('Content-Type', 'image/png'); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=600'); + const body = res.send.mock.calls[0][0]; + expect(body.subarray(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47])); + }); + + it('preview returns 404 for an unknown token', async () => { + db.query.mockResolvedValueOnce([[]]); + const req = { params: { token } }; + const res = { + set: jest.fn(), + send: jest.fn(), + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + await projectsController.getSharedProjectPreview(req, res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.send).not.toHaveBeenCalled(); + }); + + it('embed serves the Open Graph tags with escaped content and the SPA redirect', async () => { + db.query + .mockResolvedValueOnce([[{ id: 7, name: 'Neo & Tokyo', owner_name: 'Axelle' }]]) + .mockResolvedValueOnce([[]]) + .mockResolvedValueOnce([[]]) + .mockResolvedValueOnce([[]]); + const req = { params: { token } }; + const res = { + set: jest.fn(), + send: jest.fn(), + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + await projectsController.getSharedProjectEmbed(req, res); + + expect(res.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); + expect(res.set).toHaveBeenCalledWith('X-Robots-Tag', 'noindex'); + const html = res.send.mock.calls[0][0]; + expect(html).toContain( + `property="og:image" content="http://localhost:5173/api/share/${token}/preview.png"`, + ); + expect(html).toContain(`url=http://localhost:5173/s/${token}`); + expect(html).toContain('summary_large_image'); + // Markup in the project name never lands unescaped in the HTML. + expect(html).not.toContain('Neo '); + expect(html).toContain('Neo <b>'); + }); + + it('embed returns 404 for an unknown token', async () => { + db.query.mockResolvedValueOnce([[]]); + const req = { params: { token } }; + const res = { + set: jest.fn(), + send: jest.fn(), + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + await projectsController.getSharedProjectEmbed(req, res); + expect(res.status).toHaveBeenCalledWith(404); + }); + }); + describe('delete norms', () => { it('deletes a brush norm when it exists', async () => { db.query diff --git a/backend/tests/unit/sharePreview.service.test.js b/backend/tests/unit/sharePreview.service.test.js new file mode 100644 index 0000000..fa1ef73 --- /dev/null +++ b/backend/tests/unit/sharePreview.service.test.js @@ -0,0 +1,126 @@ +/** + * The social-preview renderer: SVG composition rules (escaping, truncation, + * overflow tile, near-white borders, empty fallback) and the real PNG + * rasterization through resvg with the bundled Figtree faces. + */ +const { + buildSharePreviewSvg, + getSharePreviewPngByToken, +} = require('../../src/services/sharePreview.service'); +const db = require('../../src/database'); + +jest.mock('../../src/database'); + +const baseProject = { + name: 'Alyse — Twitch émotes', + ownerName: 'Axelle', + palette: [ + { id: 1, name: 'Ink', hex: '#2E2836' }, + { id: 2, name: 'Periwinkle', hex: '#8994DF' }, + ], + brushNorms: [{}, {}], + typographyNorms: [{}], +}; + +describe('sharePreview service', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('buildSharePreviewSvg', () => { + it('draws every palette swatch with its uppercase hex label and the meta line', () => { + const svg = buildSharePreviewSvg(baseProject); + + expect(svg).toContain('fill="#2E2836"'); + expect(svg).toContain('fill="#8994DF"'); + expect(svg).toContain('>#2E2836'); + expect(svg).toContain('by Axelle · 2 colors · 3 specs'); + }); + + it('escapes markup in the project and owner names (no SVG injection)', () => { + const svg = buildSharePreviewSvg({ + ...baseProject, + name: '', + ownerName: "O'Brien & Co", + }); + + expect(svg).not.toContain('

View this reference sheet on FrameSet