// Sharing: read-only study and outline links, co-teacher invites, and the public pages that open them. import { randomUUID } from 'crypto'; import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js'; import { requireAuth } from '../auth.js'; import { checkShareAccess, expiryFromDays, hashPasscode, hideUntilReveal, revealFromInput } from '../shareLinks.js'; import { buildOutlinePayload } from '../outlineShare.js'; import { TOO_MANY, isLimited, recordFailure } from '../limiter.js'; export function registerSharingRoutes(app) { // --------------------------------------------------------------------------- // Read-only share links // --------------------------------------------------------------------------- // GET /api/projects/:id/share — current share status for the project owner app.get('/api/projects/:id/share', requireAuth, (req, res) => { try { const settings = getShareSettings(req.params.id, req.session.userId); if (!settings) return res.status(404).json({ error: 'Project not found.' }); res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode, revealAt: settings.revealAt }); } catch (err) { console.error('GET /api/projects/:id/share error:', err); res.status(500).json({ error: 'Failed to load share status.' }); } }); // Sets up (or changes) a read-only link: body { expiresInDays?: number | null, passcode?: string | null }. // Leaving a field out keeps what it was; null/"" clears it. Returns the (new or existing) token. async function saveLink(req, res, outline) { try { const userId = req.session.userId; const settings = getShareSettings(req.params.id, userId, outline); if (!settings) return res.status(404).json({ error: 'Project not found.' }); const token = settings.token || randomUUID().replace(/-/g, ''); if (!settings.token && !(outline ? setOutlineShareToken : setShareToken)(req.params.id, userId, token)) return res.status(404).json({ error: 'Project not found.' }); const body = req.body ?? {}; const expiresAt = 'expiresInDays' in body ? expiryFromDays(body.expiresInDays) : settings.expiresAt; // a blank passcode field means "keep it"; null clears; a string sets const current = getSharePassHash(req.params.id, userId, outline); const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current; // only a study link can hide its notes until a time; a past or empty time turns hiding off const revealAt = outline ? null : ('revealAt' in body ? revealFromInput(body.revealAt) : settings.revealAt); setShareSettings(req.params.id, userId, { expiresAt, passHash, revealAt }, outline); res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash, revealAt }); } catch (err) { console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err); res.status(500).json({ error: 'Failed to enable sharing.' }); } } app.post('/api/projects/:id/share', requireAuth, (req, res) => saveLink(req, res, false)); // DELETE /api/projects/:id/share — revoke the share link app.delete('/api/projects/:id/share', requireAuth, (req, res) => { try { clearShareToken(req.params.id, req.session.userId); res.json({ ok: true }); } catch (err) { console.error('DELETE /api/projects/:id/share error:', err); res.status(500).json({ error: 'Failed to revoke sharing.' }); } }); // --------------------------------------------------------------------------- // Co-teachers: other accounts that may edit a study. The owner makes a single-use invite link; whoever // opens it while signed in joins. The owner (or the co-teacher themself) can remove an editor. // --------------------------------------------------------------------------- app.get('/api/projects/:id/collaborators', requireAuth, (req, res) => { if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); res.json({ collaborators: listCollaborators(req.params.id), pendingInvites: countOpenEditInvites(req.params.id) }); }); app.post('/api/projects/:id/collaborators/invite', requireAuth, (req, res) => { if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); const token = randomUUID().replace(/-/g, ''); const expiresAt = Date.now() + 7 * 86_400_000; createEditInvite(token, req.params.id, expiresAt); res.json({ token, expiresAt }); }); app.delete('/api/projects/:id/collaborators/:userId', requireAuth, (req, res) => { const me = req.session.userId; if (!isProjectOwner(req.params.id, me) && req.params.userId !== me) return res.status(403).json({ error: 'Only the owner can remove someone else.' }); removeCollaborator(req.params.id, req.params.userId); res.json({ ok: true }); }); app.post('/api/join/:token', requireAuth, (req, res) => { const result = acceptEditInvite(req.params.token, req.session.userId); if (result.error) return res.status(400).json({ error: result.error }); res.json(result); }); // Read-only outline links. A separate token from the study share link, and the public endpoint returns // only the outlines (see outlineShare.js), never the notes or scripts. app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => { try { const settings = getShareSettings(req.params.id, req.session.userId, true); res.json({ shareToken: settings?.token ?? null, expiresAt: settings?.expiresAt ?? null, hasPasscode: !!settings?.hasPasscode }); } catch (err) { console.error('GET /api/projects/:id/outline-share error:', err); res.status(500).json({ error: 'Failed to load share status.' }); } }); app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => saveLink(req, res, true)); app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => { try { clearOutlineShareToken(req.params.id, req.session.userId); res.json({ ok: true }); } catch (err) { console.error('DELETE /api/projects/:id/outline-share error:', err); res.status(500).json({ error: 'Failed to revoke sharing.' }); } }); // A wrong passcode counts as a failed attempt for that link and address async function openSharedLink(req, res, outline) { const found = getSharedByToken(req.params.token, outline); if (!found) { res.status(404).json({ error: outline ? 'This outline link is invalid or has been turned off.' : 'This share link is invalid or has been revoked.' }); return null; } const keys = [`ip:${req.ip}`, `share:${req.params.token}`]; if (isLimited(...keys)) { res.status(429).json(TOO_MANY); return null; } const access = await checkShareAccess({ expiresAt: found.expiresAt, passHash: found.passHash, supplied: req.get('X-Share-Passcode') }); if (!access.ok) { if (access.needsPasscode && req.get('X-Share-Passcode')) recordFailure(...keys); res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 }); return null; } return found; } // GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed) app.get('/api/share-outline/:token', async (req, res) => { try { const found = await openSharedLink(req, res, true); if (found) res.json(buildOutlinePayload(found.project)); } catch (err) { console.error('GET /api/share-outline/:token error:', err); res.status(500).json({ error: 'Failed to load the shared outline.' }); } }); // GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed) app.get('/api/share/:token', async (req, res) => { try { const found = await openSharedLink(req, res, false); if (found) res.json(hideUntilReveal(found.project, found.revealAt)); } catch (err) { console.error('GET /api/share/:token error:', err); res.status(500).json({ error: 'Failed to load shared project.' }); } }); }