| … | … | @@ -67,6 +67,33 @@ async function resolveNoteOrFail (res, nm, notebook, identifier) { |
| 67 | 67 | return note |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | /** |
| 71 | * Gate read access to a notebook. PRIVATE notebooks must not be readable by |
| 72 | * slug without read authorization — the directory listing already hides them, |
| 73 | * but direct fetch bypassed that filter (broken access control). On 404/denied |
| 74 | * sends the response and returns null; otherwise returns the notebook metadata. |
| 75 | * |
| 76 | * Encrypted-but-public notebooks are NOT gated here: their content is served as |
| 77 | * ciphertext and their metadata is needed for the unlock UI. Privacy is keyed |
| 78 | * on visibility, matching the listing's `visibility !== 'private'` filter. |
| 79 | */ |
| 80 | export async function enforceNotebookReadAccess (req, res, server, nm, slug) { |
| 81 | const meta = await nm.getNotebook(slug) |
| 82 | if (!meta) { |
| 83 | json(res, { error: 'Notebook not found', code: 'NOT_FOUND', status: 404 }, 404) |
| 84 | return null |
| 85 | } |
| 86 | if (meta.visibility === 'private') { |
| 87 | const { authorized } = await server.authManager.authorizeRequest(req, 'read', slug) |
| 88 | if (!authorized) { |
| 89 | // Don't leak existence of a private notebook — mirror the not-found shape. |
| 90 | json(res, { error: 'Notebook not found', code: 'NOT_FOUND', status: 404 }, 404) |
| 91 | return null |
| 92 | } |
| 93 | } |
| 94 | return meta |
| 95 | } |
| 96 | |
| 70 | 97 | function validateNotebookSlug (res, slug) { |
| 71 | 98 | if (!NOTEBOOK_SLUG_RE.test(slug)) { |
| 72 | 99 | json(res, { error: 'Invalid notebook slug format' }, 400) |
| … | … | @@ -194,13 +221,10 @@ export function registerNotebookRoutes (router, server) { |
| 194 | 221 | json(res, notebook, 201) |
| 195 | 222 | }) |
| 196 | 223 | |
| 197 | | // Get notebook (open read) |
| 224 | // Get notebook (public read; private requires read auth) |
| 198 | 225 | router.get('/api/notebooks/:notebook', async (req, res) => { |
| 199 | | const notebook = await nm.getNotebook(req.params.notebook) |
| 200 | | if (!notebook) { |
| 201 | | json(res, { error: 'Notebook not found', code: 'NOT_FOUND', status: 404 }, 404) |
| 202 | | return |
| 203 | | } |
| 226 | const notebook = await enforceNotebookReadAccess(req, res, server, nm, req.params.notebook) |
| 227 | if (!notebook) return |
| 204 | 228 | json(res, notebook) |
| 205 | 229 | }) |
| 206 | 230 | |
| … | … | @@ -234,8 +258,9 @@ export function registerNotebookRoutes (router, server) { |
| 234 | 258 | |
| 235 | 259 | // ==================== NOTES ==================== |
| 236 | 260 | |
| 237 | | // List notes (open read) |
| 261 | // List notes (public read; private requires read auth) |
| 238 | 262 | router.get('/api/notebooks/:notebook/notes', async (req, res) => { |
| 263 | if (!(await enforceNotebookReadAccess(req, res, server, nm, req.params.notebook))) return |
| 239 | 264 | const query = parseQuery(req.url) |
| 240 | 265 | const limit = Math.min(Math.max(1, parseInt(query.limit) || 50), 500) |
| 241 | 266 | const offset = Math.max(0, parseInt(query.offset) || 0) |
| … | … | @@ -258,9 +283,10 @@ export function registerNotebookRoutes (router, server) { |
| 258 | 283 | if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {}) |
| 259 | 284 | }) |
| 260 | 285 | |
| 261 | | // Get note (open read) — accepts slug or hex ID |
| 286 | // Get note (public read; private requires read auth) — accepts slug or hex ID |
| 262 | 287 | router.get('/api/notebooks/:notebook/notes/:id', async (req, res) => { |
| 263 | 288 | if (!validateNoteIdentifier(res, req.params.id)) return |
| 289 | if (!(await enforceNotebookReadAccess(req, res, server, nm, req.params.notebook))) return |
| 264 | 290 | const note = await resolveNoteOrFail(res, nm, req.params.notebook, req.params.id) |
| 265 | 291 | if (!note) return |
| 266 | 292 | json(res, note) |