/**
* Notebook + Note + Search Routes
*
* Registers all Phase 2 HTTP endpoints for CRUD operations.
*/
import { json, readJson } from '../response.js'
import { requireAuth } from './auth-helpers.js'
import { parseRange } from '../media-store.js'
/**
* Parse query string from raw request URL
*/
function parseQuery (url) {
const idx = url.indexOf('?')
if (idx < 0) return {}
const qs = url.substring(idx + 1)
if (qs.length > 2048) return {} // Prevent DoS via huge query strings
const params = {}
const pairs = qs.split('&')
if (pairs.length > 50) return {} // Prevent excessive parameter count
for (const pair of pairs) {
const [k, v] = pair.split('=')
if (k) {
try {
params[decodeURIComponent(k)] = v ? decodeURIComponent(v) : ''
} catch {
// Skip malformed URI components
}
}
}
return params
}
const NOTE_ID_RE = /^[a-f0-9]{16}$/
const NOTE_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/
const MEDIA_FILENAME_RE = /^[a-zA-Z0-9_\-][a-zA-Z0-9_\-\.]{0,254}$/
const NOTEBOOK_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]?$/
function validateNoteId (res, id) {
if (!NOTE_ID_RE.test(id)) {
json(res, { error: 'Invalid note ID format' }, 400)
return false
}
return true
}
/**
* Validate a note identifier (slug or hex ID).
*/
function validateNoteIdentifier (res, identifier) {
if (NOTE_ID_RE.test(identifier)) return true
if (NOTE_SLUG_RE.test(identifier) && identifier.length <= 64) return true
json(res, { error: 'Invalid note identifier format' }, 400)
return false
}
/**
* Resolve a note by slug or hex ID. Returns the note or sends 404.
*/
async function resolveNoteOrFail (res, nm, notebook, identifier) {
const note = await nm.resolveNote(notebook, identifier)
if (!note) {
json(res, { error: 'Note not found', code: 'NOT_FOUND', status: 404 }, 404)
return null
}
return note
}
/**
* Gate read access to a notebook. PRIVATE notebooks must not be readable by
* slug without read authorization — the directory listing already hides them,
* but direct fetch bypassed that filter (broken access control). On 404/denied
* sends the response and returns null; otherwise returns the notebook metadata.
*
* Encrypted-but-public notebooks are NOT gated here: their content is served as
* ciphertext and their metadata is needed for the unlock UI. Privacy is keyed
* on visibility, matching the listing's `visibility !== 'private'` filter.
*/
export async function enforceNotebookReadAccess (req, res, server, nm, slug) {
const meta = await nm.getNotebook(slug)
if (!meta) {
json(res, { error: 'Notebook not found', code: 'NOT_FOUND', status: 404 }, 404)
return null
}
if (meta.visibility === 'private') {
const { authorized } = await server.authManager.authorizeRequest(req, 'read', slug)
if (!authorized) {
// Don't leak existence of a private notebook — mirror the not-found shape.
json(res, { error: 'Notebook not found', code: 'NOT_FOUND', status: 404 }, 404)
return null
}
}
return meta
}
function validateNotebookSlug (res, slug) {
if (!NOTEBOOK_SLUG_RE.test(slug)) {
json(res, { error: 'Invalid notebook slug format' }, 400)
return false
}
return true
}
function validateMediaFilename (res, filename) {
if (!MEDIA_FILENAME_RE.test(filename)) {
json(res, { error: 'Invalid media filename' }, 400)
return false
}
return true
}
/**
* Register all notebook, note, and search routes
*/
export function registerNotebookRoutes (router, server) {
const nm = server.noteManager
// ==================== NOTEBOOKS ====================
// List notebooks (open — no auth required, following wiredove model)
// ?mine=true filters to current user's notebooks only
router.get('/api/notebooks', async (req, res) => {
let notebooks = await nm.listNotebooks()
const query = parseQuery(req.url)
// Attempt auth (non-throwing) to determine visibility
let auth = null
try {
auth = await server.authManager.authenticate(req, { skipRateLimit: true })
} catch {}
// Hide private and encrypted notebooks from non-admin users
const isAdmin = auth && auth.permissions && auth.permissions.admin
if (!isAdmin) {
notebooks = notebooks.filter(nb => nb.visibility !== 'private' && !nb.encrypted)
}
// Filter to current user's notebooks
if (query.mine === 'true') {
if (auth && auth.userId) {
notebooks = notebooks.filter(nb => {
return nb.ownerId === auth.userId || (!nb.ownerId && isAdmin)
})
} else {
notebooks = []
}
}
// Filter by owner username (public notebooks only)
if (query.owner) {
const ownerUser = await server.authManager.identityRegistry.getUserByUsername(query.owner)
if (ownerUser) {
// Check if this user has admin permissions (to include unowned notebooks)
const userPerms = ownerUser.globalPermissions || []
const ownerIsAdmin = userPerms.includes('admin')
notebooks = notebooks.filter(nb => {
if (nb.ownerId === ownerUser.userId) return true
// Include unowned notebooks for the admin user
if (ownerIsAdmin && !nb.ownerId) return true
return false
})
} else {
notebooks = []
}
}
json(res, { notebooks })
})
// Recent notes feed across all public notebooks
router.get('/api/recent', async (req, res) => {
const query = parseQuery(req.url)
const limit = Math.min(parseInt(query.limit) || 20, 50)
const notes = await nm.recentNotes({ limit })
// Resolve owner usernames
const registry = server.authManager.identityRegistry
for (const note of notes) {
if (note.owner) {
try {
const user = await registry.getUser(note.owner)
note.ownerName = user ? (user.username || user.name || 'Anonymous') : 'Anonymous'
} catch { note.ownerName = 'Anonymous' }
} else {
note.ownerName = 'Anonymous'
}
}
json(res, { notes })
})
// Create notebook
router.post('/api/notebooks', async (req, res) => {
const auth = await requireAuth(req, server, 'write')
const body = await readJson(req)
let encrypted = false
if (body.encrypted && body.password) {
if (!server.notebookCrypto) {
throw new Error('Encryption not configured on server')
}
encrypted = true
}
const notebook = await nm.createNotebook(body.slug, {
name: body.name,
description: body.description,
encrypted,
visibility: body.visibility || 'public',
ownerId: auth.userId
})
// If encrypted, enable encryption and cache the key
if (encrypted) {
const key = await server.notebookCrypto.enableEncryption(body.slug, body.password)
nm.cacheKey(body.slug, key)
}
json(res, notebook, 201)
})
// Get notebook (public read; private requires read auth)
router.get('/api/notebooks/:notebook', async (req, res) => {
const notebook = await enforceNotebookReadAccess(req, res, server, nm, req.params.notebook)
if (!notebook) return
json(res, notebook)
})
// Update notebook
router.patch('/api/notebooks/:notebook', async (req, res) => {
await requireAuth(req, server, 'write', req.params.notebook)
const body = await readJson(req)
const updates = {}
if (body.name !== undefined) updates.name = body.name
if (body.description !== undefined) updates.description = body.description
if (body.encrypted !== undefined) updates.encrypted = body.encrypted
if (body.visibility !== undefined) updates.visibility = body.visibility
const notebook = await nm.updateNotebook(req.params.notebook, updates)
json(res, notebook)
})
// Delete notebook
router.delete('/api/notebooks/:notebook', async (req, res) => {
await requireAuth(req, server, 'admin', req.params.notebook)
const result = await nm.deleteNotebook(req.params.notebook)
json(res, result)
})
// Rename notebook
router.post('/api/notebooks/:notebook/rename', async (req, res) => {
await requireAuth(req, server, 'write', req.params.notebook)
const body = await readJson(req)
const notebook = await nm.renameNotebook(req.params.notebook, body.slug)
json(res, notebook)
})
// ==================== NOTES ====================
// List notes (public read; private requires read auth)
router.get('/api/notebooks/:notebook/notes', async (req, res) => {
if (!(await enforceNotebookReadAccess(req, res, server, nm, req.params.notebook))) return
const query = parseQuery(req.url)
const limit = Math.min(Math.max(1, parseInt(query.limit) || 50), 500)
const offset = Math.max(0, parseInt(query.offset) || 0)
const notes = await nm.listNotes(req.params.notebook, { limit, offset })
json(res, { notes })
})
// Create note
router.post('/api/notebooks/:notebook/notes', async (req, res) => {
await requireAuth(req, server, 'write', req.params.notebook)
const body = await readJson(req)
const note = await nm.createNote(req.params.notebook, {
title: body.title,
content: body.content,
tags: body.tags,
type: body.type
})
json(res, note, 201)
// Update site drive in background
if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {})
})
// Get note (public read; private requires read auth) — accepts slug or hex ID
router.get('/api/notebooks/:notebook/notes/:id', async (req, res) => {
if (!validateNoteIdentifier(res, req.params.id)) return
if (!(await enforceNotebookReadAccess(req, res, server, nm, req.params.notebook))) return
const note = await resolveNoteOrFail(res, nm, req.params.notebook, req.params.id)
if (!note) return
json(res, note)
})
// Update note — accepts slug or hex ID
router.patch('/api/notebooks/:notebook/notes/:id', async (req, res) => {
if (!validateNoteIdentifier(res, req.params.id)) return
await requireAuth(req, server, 'write', req.params.notebook)
const note = await resolveNoteOrFail(res, nm, req.params.notebook, req.params.id)
if (!note) return
const body = await readJson(req)
const updated = await nm.updateNote(req.params.notebook, note.id, {
title: body.title,
content: body.content,
tags: body.tags
})
json(res, updated)
// Update site drive in background
if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {})
})
// Delete note — accepts slug or hex ID
router.delete('/api/notebooks/:notebook/notes/:id', async (req, res) => {
if (!validateNoteIdentifier(res, req.params.id)) return
await requireAuth(req, server, 'write', req.params.notebook)
const note = await resolveNoteOrFail(res, nm, req.params.notebook, req.params.id)
if (!note) return
const result = await nm.deleteNote(req.params.notebook, note.id)
json(res, result)
// Update site drive in background
if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {})
})
// Move note — accepts slug or hex ID
router.post('/api/notebooks/:notebook/notes/:id/move', async (req, res) => {
if (!validateNoteIdentifier(res, req.params.id)) return
await requireAuth(req, server, 'write', req.params.notebook)
const note = await resolveNoteOrFail(res, nm, req.params.notebook, req.params.id)
if (!note) return
const body = await readJson(req)
await requireAuth(req, server, 'write', body.toNotebook)
const moved = await nm.moveNote(req.params.notebook, note.id, body.toNotebook)
json(res, moved)
})
// ==================== README ====================
// Get README (lazy migration — creates if missing)
router.get('/api/notebooks/:notebook/readme', async (req, res) => {
const readme = await nm.ensureReadme(req.params.notebook)
if (!readme) {
json(res, { error: 'README not available', code: 'NOT_FOUND', status: 404 }, 404)
return
}
json(res, readme)
})
// Regenerate TOC manually
router.post('/api/notebooks/:notebook/readme/regenerate', async (req, res) => {
await requireAuth(req, server, 'write', req.params.notebook)
await nm.regenerateToc(req.params.notebook)
const readme = await nm.getReadme(req.params.notebook)
json(res, readme || { regenerated: true })
})
// ==================== MEDIA ====================
// Upload image/media to notebook drive
router.post('/api/notebooks/:notebook/media', async (req, res) => {
await requireAuth(req, server, 'write', req.params.notebook)
// Apply media-specific rate limit
const clientIp = server.authManager.getClientIp(req)
const rateLimit = await server.authManager.rateLimiter.check(clientIp, 'media-upload')
if (!rateLimit.allowed) {
json(res, { error: 'Media upload rate limit exceeded', retryAfter: rateLimit.retryAfter }, 429)
return
}
const maxMediaSize = server.maxMediaSize || 50 * 1024 * 1024 // 50MB default
const chunks = []
let totalSize = 0
for await (const chunk of req) {
totalSize += chunk.length
if (totalSize > maxMediaSize) {
json(res, { error: `File too large (max ${Math.round(maxMediaSize / 1024 / 1024)}MB)` }, 413)
return
}
chunks.push(chunk)
}
const body = Buffer.concat(chunks)
if (body.length === 0) {
json(res, { error: 'Empty body' }, 400)
return
}
// Detect content type from header
const contentType = req.headers['content-type'] || 'application/octet-stream'
const ext = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'video/mp4': '.mp4',
'video/webm': '.webm',
'audio/mpeg': '.mp3',
'audio/ogg': '.ogg'
}[contentType] || '.bin'
// Content-addressed storage via MediaStore
const hash = await server.mediaStore.put(body, contentType)
// Detect video and enqueue for HLS processing (background, non-blocking)
if (contentType.startsWith('video/')) {
server.videoProcessor.enqueue(hash).catch(err => {
console.error('[Video] Processing failed for ' + hash.substring(0, 16) + ':', err.message)
})
}
// Cancel any pending GC for this hash (re-referenced)
server.mediaStore.cancelOrphan(hash)
// Also record in SharedNotebook for multi-writer sync if available
const snb = server.noteManager._getSharedNotebook(req.params.notebook)
if (snb) {
await snb.addMedia(null, { hash, mimeType: contentType, filename: hash, size: body.length }, 'user')
}
const url = '/api/notebooks/' + encodeURIComponent(req.params.notebook) + '/media/' + hash
json(res, { url, hash, filename: hash, size: body.length, contentType })
// Sync media to site drive in background
if (server.sitePublisher) server.sitePublisher.publishNotebook(req.params.notebook).catch(() => {})
})
// Serve media — content-addressed (MediaStore) with legacy fallback
router.get('/api/notebooks/:notebook/media/:identifier', async (req, res) => {
if (!validateMediaFilename(res, req.params.identifier)) return
const identifier = req.params.identifier
// Try MediaStore first (content-addressed)
const info = await server.mediaStore.getInfo(identifier)
if (info) {
const totalSize = info.size
const range = parseRange(req.headers.range, totalSize)
res.setHeader('Accept-Ranges', 'bytes')
res.setHeader('Content-Type', info.mimeType || 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
if (range) {
res.statusCode = 206
res.setHeader('Content-Range', 'bytes ' + range.start + '-' + range.end + '/' + totalSize)
res.setHeader('Content-Length', range.end - range.start + 1)
for await (const chunk of server.mediaStore.getStream(identifier, range)) {
res.write(chunk)
}
res.end()
} else {
res.setHeader('Content-Length', totalSize)
for await (const chunk of server.mediaStore.getStream(identifier)) {
res.write(chunk)
}
res.end()
}
return
}
// Check legacy filename → hash mapping (populated by v3 migration)
const legacyEntry = await server.mediaStore._index.get('legacy:' + identifier)
if (legacyEntry && legacyEntry.value && legacyEntry.value.hash) {
const legacyInfo = await server.mediaStore.getInfo(legacyEntry.value.hash)
if (legacyInfo) {
const totalSize = legacyInfo.size
const range = parseRange(req.headers.range, totalSize)
res.setHeader('Accept-Ranges', 'bytes')
res.setHeader('Content-Type', legacyInfo.mimeType || 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
if (range) {
res.statusCode = 206
res.setHeader('Content-Range', 'bytes ' + range.start + '-' + range.end + '/' + totalSize)
res.setHeader('Content-Length', range.end - range.start + 1)
for await (const chunk of server.mediaStore.getStream(legacyEntry.value.hash, range)) {
res.write(chunk)
}
res.end()
} else {
res.setHeader('Content-Length', totalSize)
for await (const chunk of server.mediaStore.getStream(legacyEntry.value.hash)) {
res.write(chunk)
}
res.end()
}
return
}
}
// Legacy fallback: Hyperdrive path
const drive = await server.driveManager.getDrive(req.params.notebook)
if (!drive) { res.statusCode = 404; res.end('Not found'); return }
const mediaPath = '/media/' + identifier
let buf = await drive.get(mediaPath)
// Fallback: SharedNotebook blob store
if (!buf) {
const snb = server.noteManager._getSharedNotebook(req.params.notebook)
if (snb) {
try {
await snb.update()
let entry = await snb.view.get('media-file:' + identifier)
if (!entry) entry = await snb.view.get('media:_:' + identifier)
if (entry && entry.value && entry.value.blobId) {
buf = await snb.getBlob(entry.value.blobId)
}
} catch (err) {
console.error('[Media Serve] Blob fallback error:', err.message)
}
}
}
if (!buf) {
res.statusCode = 404
res.end('Not found')
return
}
const ext = identifier.split('.').pop()
const types = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
mp4: 'video/mp4', webm: 'video/webm',
mp3: 'audio/mpeg', ogg: 'audio/ogg'
}
res.setHeader('Content-Type', types[ext] || 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
res.end(buf)
})
// ==================== VIDEO / HLS ====================
// Video processing status
router.get('/api/media/:hash/status', async (req, res) => {
const status = await server.videoProcessor.getStatus(req.params.hash)
if (!status) {
json(res, { status: 'unknown', hash: req.params.hash })
return
}
json(res, status)
})
// HLS master playlist — rewrite hash references to real segment URLs
router.get('/api/notebooks/:notebook/media/:hash/stream', async (req, res) => {
const videoIndex = await server.videoProcessor.getStatus(req.params.hash)
if (!videoIndex || videoIndex.status !== 'ready' || !videoIndex.manifest) {
res.statusCode = 404
res.end('Not found or still processing')
return
}
// Get the master playlist from MediaStore
const masterBuf = await server.mediaStore.get(videoIndex.manifest)
if (!masterBuf) {
res.statusCode = 404
res.end('Manifest not found')
return
}
// Rewrite hash references to real URLs
const basePath = (server.pathPrefix || '') + '/api/notebooks/' + encodeURIComponent(req.params.notebook) + '/media/' + req.params.hash + '/stream/'
let content = masterBuf.toString()
// Each line that's a plain hash (64 hex chars) → rewrite to level playlist URL
content = content.replace(/^([a-f0-9]{64})$/gm, function (_, hash) {
return basePath + hash
})
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl')
res.setHeader('Cache-Control', 'no-cache')
res.end(content)
})
// HLS segment/playlist serve — stream from MediaStore, rewrite level playlists
router.get('/api/notebooks/:notebook/media/:hash/stream/:segmentHash', async (req, res) => {
const segHash = req.params.segmentHash
const info = await server.mediaStore.getInfo(segHash)
if (!info) {
res.statusCode = 404
res.end('Segment not found')
return
}
// If this is a level playlist (.m3u8), rewrite .ts filenames to hash URLs
if (info.mimeType === 'application/vnd.apple.mpegurl') {
const buf = await server.mediaStore.get(segHash)
let content = buf.toString()
// Find the video index to get segment filename→hash mapping
const videoIndex = await server.videoProcessor.getStatus(req.params.hash)
if (videoIndex && videoIndex.levels) {
// Find the level that owns this m3u8
const level = videoIndex.levels.find(l => l.m3u8 === segHash)
if (level && level.segments) {
const basePath = (server.pathPrefix || '') + '/api/notebooks/' + encodeURIComponent(req.params.notebook) + '/media/' + req.params.hash + '/stream/'
// Build filename → hash map from {file, hash} pairs
const fileToHash = new Map()
for (const seg of level.segments) {
fileToHash.set(seg.file, seg.hash)
}
// Replace each .ts filename line with the hash-based URL
content = content.replace(/^(.+\.ts)$/gm, function (_, filename) {
const hash = fileToHash.get(filename)
return hash ? basePath + hash : filename
})
}
}
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl')
res.setHeader('Cache-Control', 'no-cache')
res.end(content)
return
}
// Regular segment — stream it with immutable caching
res.setHeader('Content-Type', info.mimeType || 'video/mp2t')
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
res.setHeader('Content-Length', info.size)
for await (const chunk of server.mediaStore.getStream(segHash)) {
res.write(chunk)
}
res.end()
})
// ==================== DATA EXPORT ====================
// Export single notebook (authenticated read)
router.get('/api/export/:notebook', async (req, res) => {
await requireAuth(req, server, 'read', req.params.notebook)
const { exportNotebook } = await import('../export.js')
const result = await exportNotebook(server.noteManager, server.mediaStore, req.params.notebook)
if (!result) { json(res, { error: 'Notebook not found' }, 404); return }
json(res, result)
})
// Export all notebooks (admin only)
router.get('/api/export', async (req, res) => {
await requireAuth(req, server, 'admin')
const { exportAll, APP_VERSION } = await import('../export.js')
const result = await exportAll(server.noteManager, server.mediaStore, APP_VERSION)
json(res, result)
})
// ==================== SEARCH ====================
// Search (open read)
router.get('/api/search', async (req, res) => {
const query = parseQuery(req.url)
const results = await nm.searchNotes(query.q, { notebook: query.notebook })
json(res, { results })
})
}
export default registerNotebookRoutes