Add study templates, exports, breadcrumb, whole-Bible search, bookmarks UX, and share links
- Study templates: richer multi-line guiding questions in the OIA placeholders - PDF/print export: reuses buildExportHtml in a new tab + window.print() - Markdown export: new buildMarkdownExport() with matching tests - Breadcrumb: current chunk's passage reference shown in the Study page header - Reader bookmarks: SVG icons instead of ambiguous emoji, always visible (not hover-only, so it works on touch devices), plus a Bookmarks panel that lists all saved verses across every book and jumps + scrolls to them - Whole-Bible search: no server-side search endpoint exists, so this fetches the full translation once (~7MB) and searches an in-memory flat verse index client-side, with results linking back into the reader - Read-only share links: per-project share token, a public unauthenticated /api/share/:token endpoint, and a ?share=TOKEN view that bypasses the auth gate entirely and renders the export HTML in a script-sandboxed iframe Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,12 @@ export function initDb() {
|
||||
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
|
||||
}
|
||||
|
||||
// Older databases predate shareable read-only links.
|
||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
||||
}
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
||||
|
||||
console.log(`SQLite database ready at ${DB_PATH}`);
|
||||
}
|
||||
|
||||
@@ -213,6 +219,38 @@ export function deleteProject(id, userId) {
|
||||
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns the current share token for a project owned by userId, or null. */
|
||||
export function getShareToken(id, userId) {
|
||||
const row = db.prepare('SELECT share_token AS shareToken FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
return row?.shareToken ?? null;
|
||||
}
|
||||
|
||||
/** Sets a project's share token (enabling its public read-only link), scoped to userId. */
|
||||
export function setShareToken(id, userId, token) {
|
||||
const result = db.prepare('UPDATE projects SET share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/** Revokes a project's share link, scoped to userId. */
|
||||
export function clearShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
|
||||
export function getProjectByShareToken(token) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session store backing (used by server/sessionStore.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
initDb, getAllProjects, getProject, upsertProject, deleteProject,
|
||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
} from './db.js';
|
||||
import { SqliteSessionStore } from './sessionStore.js';
|
||||
import {
|
||||
@@ -304,6 +305,59 @@ app.delete('/api/projects/:id', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 project = getProject(req.params.id, req.session.userId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: getShareToken(req.params.id, req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token
|
||||
app.post('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const existing = getShareToken(req.params.id, req.session.userId);
|
||||
const token = existing || randomUUID().replace(/-/g, '');
|
||||
const ok = setShareToken(req.params.id, req.session.userId, token);
|
||||
if (!ok) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: token });
|
||||
} catch (err) {
|
||||
console.error('POST /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
|
||||
app.get('/api/share/:token', (req, res) => {
|
||||
try {
|
||||
const project = getProjectByShareToken(req.params.token);
|
||||
if (!project) return res.status(404).json({ error: 'This share link is invalid or has been revoked.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/share/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serve Vite production build (when NODE_ENV=production)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user