- A study share link can hide the owner's notes until a chosen time: the server sends only passages and discussion questions until then, with a banner in the shared view (new share_reveal column, migrated on start). - Outline: a session length with "N of M min planned, X min over" and a marker on the point where the plan runs past it. - Export > Slides: printable 16:9 slides (title, passage, outline points with their verse, discussion questions) for one session or all; light or dark. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
153 lines
8.3 KiB
JavaScript
153 lines
8.3 KiB
JavaScript
// 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.' });
|
|
}
|
|
});
|
|
}
|