/**
* HTTP bootstrap handler — serves the first-install artifacts for a fresh
* machine. pear-git-v3 IS the deploy service, so it owns this.
*
* Layout under ${PEAR_GIT_STORAGE}/bootstrap/ (or opts.bootstrapDir):
*
* install.sh (symlink → pgh tree's scripts/)
* <app>-bootstrap-bare-<os>-<arch>.tar.gz (tar of the local app store tree)
*
* The bootstrap dir (<store>/bootstrap) is produced + owned by pgh-sidecar
* (pgh/bootstrap-artifacts): on install/OTA it TARS the local pgh + pgh-sidecar
* app trees (the apps ARE the self-contained bootstrap trees) into that dir and
* symlinks install.sh from the pgh tree (#66). So this handler stays a plain
* static file server — no reconcile, no overlay/manifest logic, no network in
* the bootstrap path: it serves whatever the sidecar has produced locally.
*
* URLs:
* GET /bootstrap/install.sh
* GET /bootstrap/<app>-bootstrap-bare-<os>-<arch>.tar.gz
*
* Missing files return 404. No directory listing, no traversal — paths are
* normalized + scoped to the bootstrap dir.
*/
import { createReadStream, statSync, existsSync } from '#node:fs'
import path from '#node:path'
import { pipeFile } from '../web/pipe-file.js'
const TYPES = {
'.sh': 'text/x-shellscript; charset=utf-8',
'.tar.gz': 'application/gzip',
'.tgz': 'application/gzip',
'.txt': 'text/plain; charset=utf-8',
'.sha256': 'text/plain; charset=utf-8'
}
function contentType (p) {
if (p.endsWith('.tar.gz')) return TYPES['.tar.gz']
return TYPES[path.extname(p)] || 'application/octet-stream'
}
/**
* Returns true if the handler consumed the request. URL is prefix-stripped by
* the caller (router.js). `bootstrapDir` may be a symlink to the materialized
* store tree — serving follows it transparently.
*/
export async function handleBootstrap (req, res, url, bootstrapDir) {
if (!url.startsWith('/bootstrap/') && url !== '/bootstrap') return false
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('method not allowed')
return true
}
const rel = url === '/bootstrap' ? '' : url.slice('/bootstrap/'.length)
if (!bootstrapDir) {
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('bootstrap dir not configured on this daemon')
return true
}
// Normalize + scope (lexical) to the bootstrap dir; refuse `..` escapes. The
// dir itself may be a symlink to the store tree — that's resolved at read.
const base = path.resolve(bootstrapDir)
const abs = path.resolve(bootstrapDir, rel)
if (!abs.startsWith(base + path.sep) && abs !== base) {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('forbidden')
return true
}
if (!existsSync(abs)) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('bootstrap artifact not found: ' + rel + '\n\nExpected layout:\n install.sh\n <app>-bootstrap-bare-<os>-<arch>.tar.gz\n')
return true
}
const st = statSync(abs)
if (st.isDirectory()) {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('directory listing disabled')
return true
}
if (req.method === 'HEAD') {
res.writeHead(200, { 'Content-Type': contentType(abs), 'Content-Length': st.size, 'Cache-Control': 'no-cache' })
res.end()
return true
}
// Content-Length omitted on the streamed body — bare-http1 emits a duplicate
// Content-Length if it's set in writeHead AND a stream is piped; let the
// response chunk. (HEAD above still advertises the size.)
res.writeHead(200, { 'Content-Type': contentType(abs), 'Cache-Control': 'no-cache' })
// pipeFile, not a bare .pipe(): these are the largest bodies the forge
// serves (multi-hundred-MB bootstrap tarballs), so an aborted download is
// routine — and unguarded it would kill the daemon (see lib/web/pipe-file.js).
return pipeFile(createReadStream(abs), res, 'bootstrap ' + rel)
}