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 0000000..4393ff6 Binary files /dev/null and b/backend/src/assets/Figtree-Bold.ttf differ diff --git a/backend/src/assets/Figtree-Medium.ttf b/backend/src/assets/Figtree-Medium.ttf new file mode 100644 index 0000000..7badbff Binary files /dev/null and b/backend/src/assets/Figtree-Medium.ttf differ diff --git a/backend/src/assets/Figtree-Regular.ttf b/backend/src/assets/Figtree-Regular.ttf new file mode 100644 index 0000000..d376f1c Binary files /dev/null and b/backend/src/assets/Figtree-Regular.ttf differ 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(` + +
+ +View this reference sheet on FrameSet
+ +`); + } 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( + `