OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / routes / web.js

348 lines · javascript ref:
/**
 * Web UI Routes — Static file serving + public URL routes
 *
 * Serves:
 * - GET /         → index.html (SPA entry point)
 * - GET /web/*    → static assets (css, js)
 * - GET /:username → list shared notebooks for user
 * - GET /:username/:book → list notes in shared notebook
 * - GET /:username/:book/:noteId → view single note
 */

import fs from 'bare-fs'
import path from 'bare-path'
import { escapeHtml } from '../response.js'

// CSP: allow self + inline styles (TOUI editor needs them) + media from self
const CSP_HEADER = [
  "default-src 'self'",
  "script-src 'self'",
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: https:",
  "media-src 'self' https:",
  "font-src 'self' data:",
  "connect-src 'self'",
  "frame-ancestors 'none'",
  "base-uri 'self'",
  "form-action 'self'"
].join('; ')

const MIME_TYPES = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.js': 'application/javascript; charset=utf-8',
  '.json': 'application/json',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
  '.woff': 'font/woff',
  '.woff2': 'font/woff2',
  '.ttf': 'font/ttf'
}

/**
 * Resolve the web root directory (relative to this file: ../../web)
 */
function getWebRoot () {
  return path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'web')
}

/**
 * Read index.html content (always fresh — no caching)
 */
function getIndexHtml (webRoot) {
  const indexPath = path.join(webRoot, 'index.html')
  return fs.readFileSync(indexPath, 'utf-8')
}

/**
 * Serve a static file with correct MIME type and security headers
 */
function serveFile (res, filePath) {
  const ext = path.extname(filePath).toLowerCase()
  const contentType = MIME_TYPES[ext] || 'application/octet-stream'

  try {
    const content = fs.readFileSync(filePath)
    res.statusCode = 200
    res.setHeader('Content-Type', contentType)
    res.setHeader('X-Content-Type-Options', 'nosniff')
    res.setHeader('Cache-Control', 'no-cache')
    if (ext === '.html') {
      res.setHeader('Content-Security-Policy', CSP_HEADER)
      res.setHeader('X-Frame-Options', 'DENY')
      res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
    }
    res.end(content)
  } catch (err) {
    res.statusCode = 404
    res.setHeader('Content-Type', 'application/json')
    res.end(JSON.stringify({ error: 'Not found' }))
  }
}

/**
 * Render a simple HTML page (for public URL views)
 */
function sendHtml (res, html, status = 200) {
  res.statusCode = status
  res.setHeader('Content-Type', 'text/html; charset=utf-8')
  res.setHeader('Content-Security-Policy', CSP_HEADER)
  res.setHeader('X-Frame-Options', 'DENY')
  res.setHeader('X-Content-Type-Options', 'nosniff')
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
  res.end(html)
}

function renderPage (title, body) {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>${escapeHtml(title)} — Hypernote</title>
  <link rel="stylesheet" href="/web/css/app.css">
  <style>
    .public-page { max-width: 800px; margin: 40px auto; padding: 0 20px; }
    .public-page h1 { margin-bottom: 8px; }
    .public-page .meta { color: #888; font-size: 0.9rem; margin-bottom: 24px; }
    .public-page .notebook-item, .public-page .note-item {
      display: block; padding: 12px 16px; margin: 8px 0;
      background: var(--bg-secondary, #f5f5f5); border-radius: 6px;
      text-decoration: none; color: inherit;
    }
    .public-page .notebook-item:hover, .public-page .note-item:hover {
      background: var(--bg-hover, #eee);
    }
    .public-page .notebook-item h3, .public-page .note-item h3 { margin: 0 0 4px; }
    .public-page .notebook-item .desc, .public-page .note-item .snippet {
      font-size: 0.85rem; color: #666;
    }
    .public-page .back-link { display: inline-block; margin-bottom: 16px; }
    .markdown-body { line-height: 1.6; }
    .public-page .tags { margin: 8px 0; }
    .public-page .tag { display: inline-block; padding: 2px 8px; font-size: 0.75rem;
      background: var(--bg-secondary, #e8e8e8); border-radius: 12px; margin-right: 4px; }
  </style>
</head>
<body>
  <div class="public-page">
    <div style="margin-bottom: 16px"><a href="/" style="font-weight:bold;text-decoration:none">Hypernote</a></div>
    ${body}
  </div>
</body>
</html>`
}

/**
 * Register web UI routes on the router
 */
export function registerWebRoutes (router, server) {
  const webRoot = getWebRoot()

  // GET / → serve index.html (SPA entry point)
  router.get('/', async (req, res) => {
    try {
      const html = getIndexHtml(webRoot)
      res.statusCode = 200
      res.setHeader('Content-Type', 'text/html; charset=utf-8')
      res.setHeader('Content-Security-Policy', CSP_HEADER)
      res.setHeader('X-Frame-Options', 'DENY')
      res.setHeader('X-Content-Type-Options', 'nosniff')
      res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
      res.end(html)
    } catch (err) {
      res.statusCode = 500
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify({ error: 'Failed to load UI' }))
    }
  })

  // GET /vendor/*path → serve vendored npm modules for browser
  router.get('/vendor/*path', async (req, res) => {
    const requestedPath = req.params.path
    const normalized = path.normalize(requestedPath)
    if (normalized.startsWith('..') || normalized.includes('/../')) {
      res.statusCode = 403
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify({ error: 'Forbidden' }))
      return
    }

    const vendorRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'node_modules')
    const filePath = path.join(vendorRoot, normalized)

    if (!filePath.startsWith(vendorRoot)) {
      res.statusCode = 403
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify({ error: 'Forbidden' }))
      return
    }

    serveFile(res, filePath)
  })

  // GET /web/*path → serve static assets
  router.get('/web/*path', async (req, res) => {
    const requestedPath = req.params.path

    // Security: prevent directory traversal
    const normalized = path.normalize(requestedPath)
    if (normalized.startsWith('..') || normalized.includes('/../')) {
      res.statusCode = 403
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify({ error: 'Forbidden' }))
      return
    }

    const filePath = path.join(webRoot, normalized)

    // Ensure resolved path is within webRoot
    if (!filePath.startsWith(webRoot)) {
      res.statusCode = 403
      res.setHeader('Content-Type', 'application/json')
      res.end(JSON.stringify({ error: 'Forbidden' }))
      return
    }

    serveFile(res, filePath)
  })

  // ==================== PUBLIC URL ROUTES ====================
  // These must be registered AFTER /api/* routes (in setupRoutes)
  // but are defined here as exports called separately.
}

/**
 * Register public URL routes (/:username, /:username/:book, /:username/:book/:noteId)
 *
 * Must be called AFTER all /api/* routes to avoid conflicts.
 */
export function registerPublicRoutes (router, server) {
  // GET /:username — List shared notebooks for user
  router.get('/:username', async (req, res) => {
    const username = req.params.username

    // Skip API-like paths and web assets
    if (username === 'api' || username === 'web' || username.startsWith('.')) {
      return false
    }

    const user = await server.authManager.identityRegistry.getUserByUsername(username)
    if (!user) {
      const html = renderPage('Not Found', '<h1>User not found</h1><p>No user with username <strong>@' + escapeHtml(username) + '</strong></p>')
      sendHtml(res, html, 404)
      return
    }

    // Get all notebooks owned by this user, filter to public ones
    const allNotebooks = await server.noteManager.listNotebooks()
    const publicNotebooks = allNotebooks.filter(nb => nb.ownerId === user.userId && nb.visibility !== 'private' && !nb.encrypted)

    let body = '<h1>@' + escapeHtml(username) + '</h1>'
    body += '<p class="meta">' + escapeHtml(user.name || 'Anonymous') + '</p>'

    if (publicNotebooks.length === 0) {
      body += '<p>No public notebooks.</p>'
    } else {
      for (const nb of publicNotebooks) {
        body += '<a class="notebook-item" href="/' + encodeURIComponent(username) + '/' + encodeURIComponent(nb.slug) + '">'
        body += '<h3>' + escapeHtml(nb.name || nb.slug) + '</h3>'
        if (nb.description) body += '<div class="desc">' + escapeHtml(nb.description) + '</div>'
        body += '<div class="desc">' + (nb.noteCount || 0) + ' notes</div>'
        body += '</a>'
      }
    }

    sendHtml(res, renderPage('@' + username, body))
  })

  // GET /:username/:book — List notes in shared notebook
  router.get('/:username/:book', async (req, res) => {
    const { username, book } = req.params

    const user = await server.authManager.identityRegistry.getUserByUsername(username)
    if (!user) {
      return sendHtml(res, renderPage('Not Found', '<h1>User not found</h1>'), 404)
    }

    // Check drive ownership and sharing
    const notebook = await server.noteManager.getNotebook(book)
    if (!notebook || notebook.ownerId !== user.userId) {
      return sendHtml(res, renderPage('Not Found', '<h1>Notebook not found</h1>'), 404)
    }

    if (notebook.visibility === 'private' || notebook.encrypted) {
      return sendHtml(res, renderPage('Private', '<h1>Notebook is private</h1><p>This notebook has not been shared publicly.</p>'), 403)
    }

    const noteList = await server.noteManager.listNotes(book)

    let body = '<a class="back-link" href="/' + encodeURIComponent(username) + '">&larr; @' + escapeHtml(username) + '</a>'
    body += '<h1>' + escapeHtml((notebook && notebook.name) || book) + '</h1>'
    body += '<p class="meta">' + noteList.length + ' notes</p>'

    if (noteList.length === 0) {
      body += '<p>No notes yet.</p>'
    } else {
      for (const note of noteList) {
        body += '<a class="note-item" href="/' + encodeURIComponent(username) + '/' + encodeURIComponent(book) + '/' + encodeURIComponent(note.id) + '">'
        body += '<h3>' + escapeHtml(note.title || 'Untitled') + '</h3>'
        if (note.content) {
          const snippet = note.content.substring(0, 120) + (note.content.length > 120 ? '...' : '')
          body += '<div class="snippet">' + escapeHtml(snippet) + '</div>'
        }
        body += '</a>'
      }
    }

    sendHtml(res, renderPage((notebook && notebook.name) || book, body))
  })

  // GET /:username/:book/:noteId — View single note
  router.get('/:username/:book/:noteId', async (req, res) => {
    const { username, book, noteId } = req.params

    const user = await server.authManager.identityRegistry.getUserByUsername(username)
    if (!user) {
      return sendHtml(res, renderPage('Not Found', '<h1>User not found</h1>'), 404)
    }

    // Check notebook ownership and visibility
    const notebook = await server.noteManager.getNotebook(book)
    if (!notebook || notebook.ownerId !== user.userId) {
      return sendHtml(res, renderPage('Not Found', '<h1>Note not found</h1>'), 404)
    }

    if (notebook.visibility === 'private' || notebook.encrypted) {
      return sendHtml(res, renderPage('Private', '<h1>Notebook is private</h1>'), 403)
    }

    // Get note
    try {
      const note = await server.noteManager.getNote(book, noteId)

      let body = '<a class="back-link" href="/' + encodeURIComponent(username) + '/' + encodeURIComponent(book) + '">&larr; ' + escapeHtml(book) + '</a>'
      body += '<h1>' + escapeHtml(note.title || 'Untitled') + '</h1>'

      if (note.tags && note.tags.length > 0) {
        body += '<div class="tags">'
        for (const t of note.tags) {
          body += '<span class="tag">' + escapeHtml(t) + '</span>'
        }
        body += '</div>'
      }

      body += '<div class="meta">' + new Date(note.updatedAt || note.createdAt).toLocaleDateString() + '</div>'
      body += '<div class="markdown-body">' + escapeHtml(note.content || '') + '</div>'

      sendHtml(res, renderPage(note.title || 'Note', body))
    } catch (err) {
      sendHtml(res, renderPage('Not Found', '<h1>Note not found</h1>'), 404)
    }
  })
}