Split server/index.js into one file per area
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Routes move to server/routes/ (auth, account, projects, reader, sharing, admin); index.js keeps setup only. A smoke test walks 90 API calls against the real server and compares every status with the pre-split behaviour. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
31ac6b9179
commit
9893883b19
12 files changed
+1337
-1040
No files matched your search
@@ -0,0 +1,150 @@
|
||||
// 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 } 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 });
|
||||
} 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;
|
||||
setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline);
|
||||
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash });
|
||||
} 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.project;
|
||||
}
|
||||
|
||||
// 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 project = await openSharedLink(req, res, true);
|
||||
if (project) res.json(buildOutlinePayload(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 project = await openSharedLink(req, res, false);
|
||||
if (project) res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/share/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user