/**
* 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) + '">← @' + 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) + '">← ' + 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)
}
})
}