OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / routes / notebooks.js

658 lines · javascript ref:
/**
 * 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