OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / static.js

168 lines · javascript ref:
/**
 * Static asset serving for vendored browser-side JS.
 *
 * Serves files from `lib/web/static/` over `GET /static/<path>`.
 * Whitelist-based: only files we explicitly know about are servable, so
 * there is NO way for a client to read arbitrary files via path traversal.
 *
 * Content types are inferred from extension.
 *
 * Caching: assets are served `immutable` with a one-year max-age, and the
 * layout stamps `?v=<ASSET_VERSION>` onto every asset URL (see helpers.js).
 * The URL therefore changes on every release, which is what makes the long
 * max-age safe — a deploy can never leave a browser pinned to stale JS.
 *
 * This replaced a blanket `no-cache, must-revalidate`. That was fine when
 * the daemon was reached over localhost, but 7rnx.net/git proxies to it
 * across a ~96ms VPN hop: with no validator and no max-age, all ten assets
 * in the layout were re-downloaded in full on every single page view, at
 * ~0.45s per round-trip and no possibility of a 304.
 */

import { readFile } from '#node:fs/promises'
import { readFileSync } from '#node:fs'
import { fileURLToPath } from '#node:url'
import { dirname, join, normalize } from '#node:path'
import { acceptsGzip, gzipIfWorthIt } from './compress.js'

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

const ONE_YEAR = 60 * 60 * 24 * 365
const ONE_HOUR = 60 * 60

/**
 * Cache-busting token stamped onto asset URLs by the layout.
 *
 * Read from package.json the same way server.js derives the version it
 * registers with the sidecar. If it can't be read we fall back to `dev`
 * and serve uncacheable — better a slow page than a browser stuck on a
 * stale bundle with no way to bust it.
 */
export const ASSET_VERSION = (() => {
  try {
    const pkg = JSON.parse(
      readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')
    )
    return pkg.version ? String(pkg.version) : 'dev'
  } catch {
    return 'dev'
  }
})()

const VERSIONED = ASSET_VERSION !== 'dev'
const VERSION_QUERY = '?v=' + encodeURIComponent(ASSET_VERSION)

/**
 * Cache policy for one request. Carrying the current `?v=` token is what
 * unlocks `immutable` — the URL is then guaranteed to change on the next
 * release, so a year-long cache can never go stale.
 *
 * Assets requested WITHOUT the token get an hour plus ETag revalidation.
 * That is not a fallback so much as a requirement: prism-autoloader builds
 * its own URLs from `languages_path` and appends the filename itself, so
 * the ~200 files under vendor/prism-components/ cannot be stamped. Serving
 * those immutable for a year would leave them un-bustable across a prism
 * upgrade.
 */
function cacheControl (req) {
  if (!VERSIONED) return 'no-cache, must-revalidate'
  const stamped = !!req && typeof req.url === 'string' &&
    req.url.includes(VERSION_QUERY)
  return stamped
    ? `public, max-age=${ONE_YEAR}, immutable`
    : `public, max-age=${ONE_HOUR}`
}

// Whitelist of servable files. Anything not in this list returns 404 even
// if the file exists on disk. This blocks path traversal AND limits the
// API surface to only what the bootstrap script needs.
// Allowed file extensions for static serving. Anything else is blocked.
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 the whitelist. Returns true if handled,
 * false if the path is unknown / not in the whitelist.
 *
 * @param {string} relPath - the path AFTER /static/, e.g. "vendor/noble-ed25519.js"
 * @param {http.ServerResponse} res
 * @param {http.IncomingMessage} [req] - needed to honour If-None-Match
 * @returns {Promise<boolean>}
 */
export async function serveStatic (relPath, res, req) {
  if (!relPath || typeof relPath !== 'string') return false

  // Strip leading slashes
  let clean = relPath.replace(/^\/+/, '')

  // Normalize and reject any traversal
  const normalized = normalize(clean)
  if (normalized.startsWith('..') || normalized.includes('/..')) return false
  if (normalized !== clean) return false

  // Extension allowlist instead of file whitelist (prism-components has 200+ files)
  const ext = normalized.lastIndexOf('.') >= 0 ? normalized.slice(normalized.lastIndexOf('.')) : ''
  if (!ALLOWED_EXTENSIONS.has(ext)) return false

  const filePath = join(STATIC_DIR, normalized)
  let body
  try {
    body = await readFile(filePath)
  } catch {
    return false
  }

  // Weak-ish validator: the release version plus the byte length. Content
  // hashing would be stronger, but the URL is already version-stamped, so
  // this only has to catch "same asset, same release" — and it costs no
  // extra read. Gives a cheap 304 on the `dev` path, where we can't send a
  // max-age at all.
  const etag = `"${ASSET_VERSION}-${body.byteLength}"`
  const cc = cacheControl(req)

  if (req && req.headers && req.headers['if-none-match'] === etag) {
    res.writeHead(304, {
      ETag: etag,
      'Cache-Control': cc
    })
    res.end()
    return true
  }

  // Content-Length is intentionally NOT set here: res.end(body) derives it.
  // Setting it explicitly AND passing a body makes bare-http1 emit a second
  // Content-Length header, which its (stricter-than-node) parser rejects.
  const headers = {
    'Content-Type': contentType(normalized),
    'Cache-Control': cc,
    ETag: etag,
    Vary: 'Accept-Encoding'
  }

  // The assets are all JS/CSS/JSON — text that compresses well, and every
  // one of them crosses the same slow proxy hop as the page itself.
  const gz = acceptsGzip(req) ? gzipIfWorthIt(body) : null
  if (gz) {
    headers['Content-Encoding'] = 'gzip'
    res.writeHead(200, headers)
    res.end(gz)
    return true
  }

  res.writeHead(200, headers)
  res.end(body)
  return true
}