OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / static.js

60 lines · javascript ref:
// Static asset serving for favela's browser-side JS (the sign-in client + vendored crypto).
// Serves files from `web/static/` over `GET /static/<path>`. Bare-native (bare-fs/bare-path/bare-url).
//
// Adapted from pear-git-v3's lib/web/static.js: extension allowlist (not a per-file whitelist) +
// path-traversal guard, so a client can never read arbitrary files. Content-Length is intentionally
// NOT set — res.end(body) derives it; setting it explicitly makes bare-http1 emit a duplicate header
// its (stricter-than-node) parser rejects.
import { readFile } from 'bare-fs/promises'
import { fileURLToPath } from 'bare-url'
import { dirname, join, normalize } from 'bare-path'
import { STATIC_ASSETS } from '../web/static-assets.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const STATIC_DIR = join(__dirname, '..', 'web', 'static')

const ALLOWED_EXTENSIONS = new Set(['.js', '.css', '.json', '.map'])
const CONTENT_TYPES = {
  '.js': 'application/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.json': 'application/json'
}
function contentType (path) {
  const dot = path.lastIndexOf('.')
  if (dot < 0) return 'application/octet-stream'
  return CONTENT_TYPES[path.slice(dot)] || 'application/octet-stream'
}

// Serve a static file from web/static/. Returns true if handled, false if unknown/blocked (caller 404s).
// relPath is the path AFTER /static/, e.g. "vendor/noble-secp256k1.js".
export async function serveStatic (relPath, res) {
  if (!relPath || typeof relPath !== 'string') return false
  const clean = relPath.replace(/^\/+/, '')
  const normalized = normalize(clean)
  if (normalized.startsWith('..') || normalized.includes('/..')) return false
  if (normalized !== clean) return false
  const dot = normalized.lastIndexOf('.')
  const ext = dot >= 0 ? normalized.slice(dot) : ''
  if (!ALLOWED_EXTENSIONS.has(ext)) return false

  // ⛔ FILESYSTEM FIRST, BUNDLE SECOND. On a server web/static/ is a real directory and this read is the
  // whole story. Inside a bundle it is not: bare-pack packs the module GRAPH, these files are imported by
  // nothing, and readFile throws — which is exactly what favela-android has been doing since the WebView
  // pivot. Measured on the handset 2026-08-18: /static/compose.js and /static/actions.js both 404, so
  // image attach, like, repost, follow, drafts and reporting had NEVER worked there. The markup arrived
  // for free and the behaviour silently did not.
  //
  // The generated module is the copy that survives packing. Filesystem stays first so a server picks up
  // an edited file without regenerating, and test/static-assets.test.mjs fails if the two ever disagree.
  let body
  try { body = await readFile(join(STATIC_DIR, normalized)) } catch { body = null }
  if (body === null) {
    const src = STATIC_ASSETS[normalized]
    if (src === undefined) return false
    body = Buffer.from(src, 'utf8')
  }
  res.writeHead(200, { 'Content-Type': contentType(normalized), 'Cache-Control': 'no-cache, must-revalidate' })
  res.end(body)
  return true
}