Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions backend/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
216 changes: 216 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added backend/src/assets/Figtree-Bold.ttf
Binary file not shown.
Binary file added backend/src/assets/Figtree-Medium.ttf
Binary file not shown.
Binary file added backend/src/assets/Figtree-Regular.ttf
Binary file not shown.
83 changes: 83 additions & 0 deletions backend/src/controllers/projects.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 <meta refresh> sends everyone else to the SPA.
res.send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${title}</title>
<meta name="description" content="${description}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="FrameSet">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta property="og:url" content="${escapeHtml(pageUrl)}">
<meta property="og:image" content="${escapeHtml(imageUrl)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${escapeHtml(imageUrl)}">
<meta http-equiv="refresh" content="0;url=${escapeHtml(pageUrl)}">
<link rel="canonical" href="${escapeHtml(pageUrl)}">
</head>
<body>
<p><a href="${escapeHtml(pageUrl)}">View this reference sheet on FrameSet</a></p>
</body>
</html>`);
} 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) => {
Expand Down Expand Up @@ -672,6 +753,8 @@ module.exports = {
enableSharing,
disableSharing,
getSharedProject,
getSharedProjectPreview,
getSharedProjectEmbed,
addBrushNorm,
addTypographyNorm,
updatePalette,
Expand Down
Loading
Loading