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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ from one drawing to the next.
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.
instead of a generic logo card. And the shared page is **live**: anyone
viewing it sees your edits appear in real time (Server-Sent Events under
the hood), including the link being revoked.
- **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
15 changes: 8 additions & 7 deletions backend/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,14 @@ 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 |
| `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 |
| 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 |
| `GET` | `/share/:token/events` | – | Server-Sent Events stream: a bare `changed` event fires on every owner mutation (subscribers refetch the share endpoint); heartbeats keep proxies alive, and a `full` event ends the stream when the per-project viewer cap is reached |

`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
38 changes: 38 additions & 0 deletions backend/src/controllers/projects.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
const { getAuthenticatedUserId, createControllerLogger } = require('../utils/auth.utils');
const projectsService = require('../services/projects.service');
const sharePreviewService = require('../services/sharePreview.service');
const shareEventsService = require('../services/shareEvents.service');

const logProjectsControllerError = createControllerLogger('projects');

Expand Down Expand Up @@ -301,6 +302,42 @@ const getSharedProjectEmbed = async (req, res) => {
}
};

// PUBLIC (no auth): the live-update stream behind a shared page. Long-lived
// SSE response; subscribers get a bare `changed` ping whenever the owner
// mutates the project (see the notify middleware in projects.routes) and
// refetch the share endpoint themselves — no content ever flows through here.
const getSharedProjectEvents = async (req, res) => {
let projectId;
try {
projectId = await projectsService.getSharedProjectIdByToken(req.params.token);
} catch (error) {
if (error.code === 'not_found') {
return res.status(404).json({ error: 'This link is no longer active.' });
}
logProjectsControllerError(req, 'get_shared_events', error);
return res.status(500).json({ error: 'Server error.' });
}

res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// Tells nginx-style proxies not to buffer the stream.
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
// EventSource reconnect delay after a drop (proxies do reap long requests).
res.write('retry: 3000\n\n');

if (!shareEventsService.subscribe(projectId, res)) {
// Per-project cap reached: end politely, the page just isn't live.
res.write('event: full\ndata: {}\n\n');
return res.end();
}

req.on('close', () => shareEventsService.unsubscribe(projectId, res));
};

// 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 @@ -755,6 +792,7 @@ module.exports = {
getSharedProject,
getSharedProjectPreview,
getSharedProjectEmbed,
getSharedProjectEvents,
addBrushNorm,
addTypographyNorm,
updatePalette,
Expand Down
22 changes: 22 additions & 0 deletions backend/src/docs/paths/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,28 @@ module.exports = {
},
},
},
'/api/share/{token}/events': {
get: {
tags: ['Projects'],
summary: 'PUBLIC: live-update stream for a shared page (no auth, SSE)',
description:
'A Server-Sent Events stream (`text/event-stream`). Subscribers receive a bare ' +
'`changed` event whenever the owner mutates the project (palette, standards, name, ' +
'trash/restore, revocation) and are expected to refetch `/api/share/{token}` — no ' +
'content flows through the stream itself. Heartbeat comments keep proxies from ' +
'reaping the connection; when a project reaches its viewer cap the server sends a ' +
'`full` event and ends the stream.',
parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
200: {
description: 'The event stream.',
content: { 'text/event-stream': { schema: { type: 'string' } } },
},
404: { $ref: '#/components/responses/NotFound' },
429: { $ref: '#/components/responses/RateLimited' },
},
},
},
'/api/projects/{id}/brush-norms': {
post: {
tags: ['Projects'],
Expand Down
17 changes: 17 additions & 0 deletions backend/src/routes/projects.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,26 @@ const express = require('express');
const projectsController = require('../controllers/projects.controller');
const authenticateToken = require('../middleware/authenticateToken');
const { projectCreateLimiter, paletteWriteLimiter } = require('../middleware/projectCreateLimiter');
const shareEvents = require('../services/shareEvents.service');

const router = express.Router();

// Live share: after ANY successful mutation under /projects/:id/…, ping the
// SSE subscribers of that project's shared page (see shareEvents.service).
// One hook for every current and future mutating route, so a new endpoint can
// never forget to notify. The id is parsed from the path because Express
// resets req.params once the route layer unwinds (before 'finish' fires).
router.use((req, res, next) => {
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return next();
const match = req.path.match(/^\/(\d+)(?:\/|$)/);
if (!match) return next();
const projectId = Number(match[1]);
res.on('finish', () => {
if (res.statusCode < 300) shareEvents.notifyProjectChanged(projectId);
});
next();
});

router.get('/', authenticateToken, projectsController.listProjects);
// Global search (Ctrl+K): one term across project names, palette colors and
// standards. Literal segment, kept clear of the '/:id' patterns below.
Expand Down
1 change: 1 addition & 0 deletions backend/src/routes/share.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const shareViewLimiter = rateLimit({
// 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/events', shareViewLimiter, projectsController.getSharedProjectEvents);
router.get('/:token', shareViewLimiter, projectsController.getSharedProject);

module.exports = router;
19 changes: 19 additions & 0 deletions backend/src/services/projectSharing.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,28 @@ const getSharedProjectByToken = async (rawToken) => {
};
};

// Resolves a share token to just the project id (for the live-events stream,
// which subscribes by project and never sends content itself). Same token
// validation and same 'not_found' contract as the full read.
const getSharedProjectIdByToken = async (rawToken) => {
const token = typeof rawToken === 'string' ? rawToken.trim() : '';
if (!SHARE_TOKEN_PATTERN.test(token)) {
throw new ProjectServiceError('not_found');
}
const [rows] = await db.query(
'SELECT id FROM projects WHERE share_token = ? AND deleted_at IS NULL',
[token],
);
if (rows.length === 0) {
throw new ProjectServiceError('not_found');
}
return rows[0].id;
};

module.exports = {
fetchLiveProjectChildren,
enableProjectSharing,
disableProjectSharing,
getSharedProjectByToken,
getSharedProjectIdByToken,
};
2 changes: 2 additions & 0 deletions backend/src/services/projects.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const {
enableProjectSharing,
disableProjectSharing,
getSharedProjectByToken,
getSharedProjectIdByToken,
} = require('./projectSharing.service');

// Upper bound on palette size to cap per-request work and storage.
Expand Down Expand Up @@ -1011,6 +1012,7 @@ module.exports = {
enableProjectSharing,
disableProjectSharing,
getSharedProjectByToken,
getSharedProjectIdByToken,
TRASH_RETENTION_DAYS,
addBrushNormToProject,
addTypographyNormToProject,
Expand Down
115 changes: 115 additions & 0 deletions backend/src/services/shareEvents.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Live-share event hub: an in-memory registry of SSE subscribers per project,
* so an open shared page can refetch the sheet the moment the owner edits it.
*
* Deliberately minimal: events carry NO data — subscribers just get a
* `changed` ping and re-read the public share endpoint, so the shared page
* has exactly one code path for content (and revocation naturally surfaces
* as the refetch 404ing). Single-process by design, which matches the one
* Railway instance; if the API ever scales out, this is the seam where a
* pub/sub backend (e.g. Redis) would slot in.
*/

// projectId -> Set<res> of open SSE responses.
const subscribersByProject = new Map();

// Caps runaway fan-out from one very popular link; beyond this, extra viewers
// simply don't get live updates (the page still works, just not live).
const MAX_SUBSCRIBERS_PER_PROJECT = 100;

// Proxies (Railway's included) reap idle connections; a periodic comment line
// keeps them open. One shared timer for all subscribers, started lazily and
// stopped when nobody is listening (also lets Jest exit cleanly).
const HEARTBEAT_MS = 25000;
let heartbeatTimer = null;

const totalSubscribers = () => {
let count = 0;
subscribersByProject.forEach((set) => {
count += set.size;
});
return count;
};

const stopHeartbeatIfIdle = () => {
if (heartbeatTimer && totalSubscribers() === 0) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};

const startHeartbeat = () => {
if (heartbeatTimer) return;
heartbeatTimer = setInterval(() => {
subscribersByProject.forEach((set) => {
set.forEach((res) => {
try {
res.write(': ping\n\n');
} catch {
/* the close handler removes broken subscribers */
}
});
});
}, HEARTBEAT_MS);
// Never keep the process alive just for heartbeats.
if (heartbeatTimer.unref) heartbeatTimer.unref();
};

/**
* Registers an open SSE response for a project. Returns false when the
* per-project cap is reached (caller ends the stream gracefully).
* The caller is responsible for calling unsubscribe on connection close.
*/
const subscribe = (projectId, res) => {
let set = subscribersByProject.get(projectId);
if (!set) {
set = new Set();
subscribersByProject.set(projectId, set);
}
if (set.size >= MAX_SUBSCRIBERS_PER_PROJECT) return false;
set.add(res);
startHeartbeat();
return true;
};

const unsubscribe = (projectId, res) => {
const set = subscribersByProject.get(projectId);
if (!set) return;
set.delete(res);
if (set.size === 0) subscribersByProject.delete(projectId);
stopHeartbeatIfIdle();
};

/**
* Tells every open shared page of this project to refetch. Fire-and-forget:
* a broken pipe never breaks the mutation that triggered the notify.
*/
const notifyProjectChanged = (projectId) => {
const set = subscribersByProject.get(projectId);
if (!set) return;
set.forEach((res) => {
try {
res.write('event: changed\ndata: {}\n\n');
} catch {
/* the close handler removes broken subscribers */
}
});
};

// Test hook: drops every subscriber and stops the heartbeat.
const resetForTests = () => {
subscribersByProject.clear();
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};

module.exports = {
subscribe,
unsubscribe,
notifyProjectChanged,
resetForTests,
MAX_SUBSCRIBERS_PER_PROJECT,
HEARTBEAT_MS,
};
51 changes: 51 additions & 0 deletions backend/tests/unit/projects.controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,10 @@ describe('projects controller', () => {

describe('shared project social preview (public)', () => {
const token = 'a'.repeat(32);

afterEach(() => {
require('../../src/services/shareEvents.service').resetForTests();
});
const mockSharedProjectQueries = () => {
db.query
.mockResolvedValueOnce([[{ id: 7, name: 'Neo-Tokyo', owner_name: 'Axelle' }]])
Expand Down Expand Up @@ -932,6 +936,53 @@ describe('projects controller', () => {
expect(html).toContain('Neo &lt;b&gt;');
});

it('events opens an SSE stream, subscribes the project and cleans up on close', async () => {
const shareEvents = require('../../src/services/shareEvents.service');
db.query.mockResolvedValueOnce([[{ id: 7 }]]);
const closeHandlers = {};
const req = { params: { token }, on: jest.fn((event, cb) => (closeHandlers[event] = cb)) };
const res = {
set: jest.fn(),
flushHeaders: jest.fn(),
write: jest.fn(),
end: jest.fn(),
json: jest.fn(),
status: jest.fn().mockReturnThis(),
};
await projectsController.getSharedProjectEvents(req, res);

expect(res.set).toHaveBeenCalledWith(
expect.objectContaining({ 'Content-Type': 'text/event-stream' }),
);
expect(res.flushHeaders).toHaveBeenCalled();
expect(res.write).toHaveBeenCalledWith('retry: 3000\n\n');

// The stream is genuinely registered: a notify reaches it…
shareEvents.notifyProjectChanged(7);
expect(res.write).toHaveBeenCalledWith('event: changed\ndata: {}\n\n');

// …and closing the request unsubscribes it.
closeHandlers.close();
res.write.mockClear();
shareEvents.notifyProjectChanged(7);
expect(res.write).not.toHaveBeenCalled();
});

it('events returns 404 for an unknown token without opening a stream', async () => {
db.query.mockResolvedValueOnce([[]]);
const req = { params: { token }, on: jest.fn() };
const res = {
set: jest.fn(),
flushHeaders: jest.fn(),
write: jest.fn(),
json: jest.fn(),
status: jest.fn().mockReturnThis(),
};
await projectsController.getSharedProjectEvents(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.flushHeaders).not.toHaveBeenCalled();
});

it('embed returns 404 for an unknown token', async () => {
db.query.mockResolvedValueOnce([[]]);
const req = { params: { token } };
Expand Down
Loading
Loading