import { realpathSync, statSync, readFileSync, openSync, readSync, closeSync, readdirSync } from "fs"; import { isAbsolute, relative, join, dirname, resolve } from "path"; import hljs from "highlight.js"; import { THEME_CSS, escapeHtml, escapeAttr } from "./render/layout"; import { renderMarkdown } from "./render/markdown"; import { BUILD_ID } from "./build"; import type { Config } from "./config"; export class FileViewError extends Error { status: number; constructor(message: string, status: number) { super(message); this.name = "FileViewError"; this.status = status; } } // Defense-in-depth denylist — always rejected even inside an allowlisted root. // Tested on both the raw input and the realpath, so symlinks can't bypass. const DENY = [ /(^|\/)\.env\b/i, /(^|\/)\.(ssh|gnupg|aws|config\/gitea)\b/i, /(id_rsa|id_ed25519|id_ecdsa|id_dsa)\b/i, /^\/(?:etc|proc|sys|dev|boot|root)\b/i, /\/\.git\/(?:config|hooks|HEAD)\b/i, ]; export interface LineRow { n: number; t: string; } export interface FileChunk { realPath: string; rows: LineRow[]; mode: "tail" | "head"; order: "asc" | "desc"; byteCapped: boolean; note: string; size: number; } export interface FileDelta { rows: LineRow[]; size: number; rotated: boolean; capped: boolean; } // Shared security gate for /file and /api/file/since: absolute path → denylist // (raw) → realpath → denylist (realpath) → allowlist containment → regular file. // Returns the realpath; caller does the read. function validateFilePath(cfg: Config, rawPath: string): string { if (!rawPath || !isAbsolute(rawPath)) { throw new FileViewError("path must be absolute", 400); } for (const re of DENY) if (re.test(rawPath)) { throw new FileViewError("denied: sensitive path", 403); } let rp: string; try { rp = realpathSync(rawPath); } catch { throw new FileViewError("file not found", 404); } for (const re of DENY) if (re.test(rp)) { throw new FileViewError("denied: sensitive path", 403); } // Allowlist: realpath must be strictly within a root. Both root and target are // realpath-resolved so a root symlink is followed consistently; relative() with // no leading ".." and not absolute => target is contained under the root. const inRoot = cfg.fileRoots.some((r) => { if (!isAbsolute(r)) return false; let rr: string; try { rr = realpathSync(r); } catch { return false; } const rel = relative(rr, rp); return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel); }); if (!inRoot) throw new FileViewError("denied: outside allowlisted roots", 403); return rp; } function validateReadableFile(cfg: Config, rawPath: string): { rp: string; size: number } { const rp = validateFilePath(cfg, rawPath); let st: ReturnType; try { st = statSync(rp); } catch { throw new FileViewError("stat failed", 404); } if (!st.isFile()) throw new FileViewError("not a regular file", 400); return { rp, size: st.size }; } // Like validateReadableFile but accepts directories too — caller branches on // isDirectory(). Same allowlist/denylist/realpath gate applies to dirs. function validatePath(cfg: Config, rawPath: string): { rp: string; isDir: boolean } { const rp = validateFilePath(cfg, rawPath); let st: ReturnType; try { st = statSync(rp); } catch { throw new FileViewError("stat failed", 404); } if (st.isDirectory()) return { rp, isDir: true }; if (!st.isFile()) throw new FileViewError("not a regular file or directory", 400); return { rp, isDir: false }; } export function readAllowedFile( cfg: Config, rawPath: string, mode: "tail" | "head", order: "asc" | "desc" ): FileChunk { const { rp, size } = validateReadableFile(cfg, rawPath); const cap = cfg.fileMaxBytes; let buf: Buffer; let byteCapped = false; if (size > cap) { // Slice only `cap` bytes from the chosen end — avoids loading multi-MB/GB // logs into memory. Tail reads the last cap; head reads the first cap. const start = mode === "tail" ? size - cap : 0; buf = readSlice(rp, start, cap); byteCapped = true; } else { try { buf = readFileSync(rp); } catch (e) { throw new FileViewError(`read failed: ${(e as Error).message}`, 500); } } // Binary detection: a NUL byte in the first 8KB => not a text log. const scanLen = Math.min(buf.length, 8192); for (let i = 0; i < scanLen; i++) if (buf[i] === 0) { throw new FileViewError("binary file (not viewable as text)", 415); } const text = buf.toString("utf-8"); let lines = text.split("\n"); if (lines.length && lines[lines.length - 1] === "") lines.pop(); const totalLines = lines.length; const n = cfg.fileMaxLines; let shown: string[]; let note: string; if (byteCapped) { if (mode === "tail") { shown = lines.slice(-n); // First line of a tail slice is likely truncated mid-line — clobber it. if (shown.length > 1) shown[0] = "(…前文已截断)"; note = `文件 ${formatBytes(size)},仅读取末尾 ${formatBytes(cap)} 的最后 ${shown.length} 行`; } else { shown = lines.slice(0, n); note = `文件 ${formatBytes(size)},仅读取开头 ${formatBytes(cap)} 的前 ${shown.length} 行`; } } else if (totalLines <= n) { shown = lines; note = `共 ${totalLines} 行 · ${formatBytes(size)}`; } else if (mode === "tail") { shown = lines.slice(-n); note = `共 ${totalLines} 行,显示末尾 ${n} 行`; } else { shown = lines.slice(0, n); note = `共 ${totalLines} 行,显示前 ${n} 行`; } // Original line numbers (unknown when byte-capped → positional 1..N). let firstNum: number; if (byteCapped) { firstNum = 1; } else if (mode === "tail") { firstNum = totalLines - shown.length + 1; } else { firstNum = 1; } let rows = shown.map((t, i) => ({ n: firstNum + i, t })); if (order === "desc") rows = rows.reverse(); return { realPath: rp, rows, mode, order, byteCapped, note, size }; } // Live tail -f: returns only bytes appended after `afterOffset`. `rotated` // (size shrank) tells the client to reload; client seeds afterOffset from page size. export function readFileSince(cfg: Config, rawPath: string, afterOffset: number): FileDelta { if (!Number.isFinite(afterOffset) || afterOffset < 0) { throw new FileViewError("bad after offset", 400); } const { rp, size } = validateReadableFile(cfg, rawPath); if (size < afterOffset) return { rows: [], size, rotated: true, capped: false }; if (size === afterOffset) return { rows: [], size, rotated: false, capped: false }; const cap = cfg.fileMaxBytes; const want = Math.min(size - afterOffset, cap); const buf = readSlice(rp, afterOffset, want); const scanLen = Math.min(buf.length, 8192); for (let i = 0; i < scanLen; i++) if (buf[i] === 0) { throw new FileViewError("binary file (not viewable as text)", 415); } const text = buf.toString("utf-8"); let lines = text.split("\n"); if (lines.length && lines[lines.length - 1] === "") lines.pop(); const capped = size - afterOffset >= cap; if (capped && lines.length > 1) lines[0] = "(…前文已截断)"; const n = cfg.fileMaxLines; if (lines.length > n) lines = lines.slice(-n); const rows = lines.map((t) => ({ n: 0, t })); return { rows, size, rotated: false, capped }; } function readSlice(path: string, start: number, len: number): Buffer { const fd = openSync(path, "r"); try { const buf = Buffer.alloc(len); const got = readSync(fd, buf, 0, len, start); return buf.subarray(0, got); } finally { closeSync(fd); } } function formatBytes(n: number): string { if (n < 1024) return `${n}B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`; if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`; return `${(n / (1024 * 1024 * 1024)).toFixed(2)}GB`; } // Log files default newest-first (tail + desc); documents head + asc. function isLogPath(p: string): boolean { return /\.log(\.\d+)?$/i.test(p) || /\.out$/i.test(p); } const EXT_LANG: Record = { py: "python", ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", mjs: "javascript", java: "java", kt: "kotlin", go: "go", rs: "rust", rb: "ruby", php: "php", c: "c", h: "c", cpp: "cpp", cc: "cpp", hpp: "cpp", cxx: "cpp", cs: "csharp", sh: "bash", bash: "bash", zsh: "bash", sql: "sql", json: "json", yaml: "yaml", yml: "yaml", toml: "toml", xml: "xml", html: "xml", htm: "xml", css: "css", scss: "scss", less: "less", md: "markdown", markdown: "markdown", scala: "scala", swift: "swift", lua: "lua", pl: "perl", }; function extToLang(p: string): string { const base = (p.split("/").pop() || "").toLowerCase(); if (base === "dockerfile" || base.endsWith(".dockerfile")) return "dockerfile"; const m = base.match(/\.([a-z0-9]+)$/); const ext = m && m[1]; return ext ? (EXT_LANG[ext] || "") : ""; } function hljsHighlight(text: string, lang: string): string { try { return hljs.highlight(text, { language: lang }).value; } catch { return escapeHtml(text); } } function fmtSize(n: number): string { if (n < 1024) return `${n}B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`; return `${(n / (1024 * 1024)).toFixed(1)}MB`; } // Compact relative/short mtime for directory rows — kept short on purpose so two // fixed columns (time + size) don't starve the name on narrow phone screens. function fmtMtime(ms: number): string { if (!ms) return ""; const diff = Date.now() - ms; if (diff < 0) return "刚刚"; if (diff < 60000) return "刚刚"; if (diff < 3600000) return `${Math.floor(diff / 60000)}分前`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}时前`; if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`; const d = new Date(ms); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); if (d.getFullYear() === new Date().getFullYear()) return `${mm}-${dd}`; return `${String(d.getFullYear()).slice(-2)}-${mm}-${dd}`; } // Directory listing (FTP-style): parent link + entries (dirs first), each // clickable to /file?path= which re-validates + branches dir/file. function breadcrumbHTML(rp: string, cfg: Config): string { const segs = rp.split("/").filter(Boolean); let roots: string[] = []; try { roots = cfg.fileRoots.map((r) => realpathSync(r)); } catch { /* ignore */ } const within = (p: string) => roots.some((root) => p === root || p.startsWith(root + "/")); const parts: string[] = []; let acc = ""; segs.forEach((seg, i) => { acc += "/" + seg; if (i === segs.length - 1) parts.push(`${escapeHtml(seg)}`); else if (within(acc)) parts.push(`${escapeHtml(seg)}`); else parts.push(`${escapeHtml(seg)}`); }); return `/` + parts.join(`/`); } export function buildDirView(cfg: Config, rp: string, sortReq: string | undefined, tdirReq: string | undefined): { html: string } { type Ent = { name: string; isDir: boolean; size: number; mtime: number }; const ents: Ent[] = []; try { for (const d of readdirSync(rp, { withFileTypes: true })) { let size = 0; let mtime = 0; try { const st = statSync(join(rp, d.name)); size = st.size; mtime = st.mtimeMs; } catch { /* unreadable entry */ } ents.push({ name: d.name, isDir: d.isDirectory(), size, mtime }); } } catch (e) { throw new FileViewError(`readdir failed: ${(e as Error).message}`, 500); } const sortByTime = sortReq !== "name"; const tdir: "asc" | "desc" = tdirReq === "asc" ? "asc" : "desc"; ents.sort((a, b) => { if (sortByTime) { const c = a.mtime - b.mtime; return tdir === "desc" ? b.mtime - a.mtime : c; } return a.isDir === b.isDir ? a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) : a.isDir ? -1 : 1; }); const parent = dirname(rp); const parentInRoot = cfg.fileRoots.some((r) => { if (!isAbsolute(r)) return false; let rr: string; try { rr = realpathSync(r); } catch { return false; } const rel = relative(rr, parent); return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel); }); const enc = encodeURIComponent(rp); const rows = ents.map((e) => { const href = `/file?path=${encodeURIComponent(join(rp, e.name))}`; const icon = e.isDir ? "📁" : "📄"; const nm = e.isDir ? `${e.name}/` : e.name; return `${icon}${escapeHtml(nm)}${fmtMtime(e.mtime)}${e.isDir ? "" : fmtSize(e.size)}`; }).join(""); const parentRow = parentInRoot ? `📁..` : ""; const nameQ = `?path=${enc}&sort=name`; const timeDescQ = `?path=${enc}&sort=time&tdir=desc`; const timeAscQ = `?path=${enc}&sort=time&tdir=asc`; const sortBar = `
名称时间${sortByTime ? `新→旧旧→新` : ""}
`; const html = ` ${escapeHtml(basename(rp) || rp)} · 目录
${breadcrumbHTML(rp, cfg)}
${sortBar}
${parentRow}${rows || `
空目录
`}
`; return { html }; } const MEDIA: Record = { mp3: { kind: "audio", mime: "audio/mpeg" }, wav: { kind: "audio", mime: "audio/wav" }, ogg: { kind: "audio", mime: "audio/ogg" }, m4a: { kind: "audio", mime: "audio/mp4" }, flac: { kind: "audio", mime: "audio/flac" }, aac: { kind: "audio", mime: "audio/aac" }, mp4: { kind: "video", mime: "video/mp4" }, webm: { kind: "video", mime: "video/webm" }, mov: { kind: "video", mime: "video/quicktime" }, mkv: { kind: "video", mime: "video/x-matroska" }, avi: { kind: "video", mime: "video/x-msvideo" }, png: { kind: "image", mime: "image/png" }, jpg: { kind: "image", mime: "image/jpeg" }, jpeg: { kind: "image", mime: "image/jpeg" }, gif: { kind: "image", mime: "image/gif" }, webp: { kind: "image", mime: "image/webp" }, bmp: { kind: "image", mime: "image/bmp" }, svg: { kind: "image", mime: "image/svg+xml" }, pdf: { kind: "pdf", mime: "application/pdf" }, }; function mediaKind(p: string): "" | "audio" | "video" | "image" | "pdf" { const b = basename(p); const i = b.lastIndexOf("."); if (i < 0) return ""; return MEDIA[b.slice(i + 1).toLowerCase()]?.kind ?? ""; } function mimeOf(p: string): string { const b = basename(p); const i = b.lastIndexOf("."); if (i < 0) return "application/octet-stream"; return MEDIA[b.slice(i + 1).toLowerCase()]?.mime ?? "application/octet-stream"; } // Stream a file's raw bytes for /file/raw (inline: media player src) or /file/dl // (attachment: forced download). Same gate as /file; large files stream via // Bun.file without buffering into memory. export function serveRawFile(cfg: Config, rawPath: string, disposition: "inline" | "attachment"): Response { const { rp, size } = validateReadableFile(cfg, rawPath); const name = basename(rp); const headers: Record = { "content-type": mimeOf(rp), "content-length": String(size), "cache-control": "private, max-age=0", }; if (disposition === "attachment") { // RFC 5987 filename* for non-ASCII; ASCII fallback for older clients. const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, ""); headers["content-disposition"] = `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`; } else { headers["content-disposition"] = "inline"; } return new Response(Bun.file(rp), { headers }); } function buildMediaView(cfg: Config, rp: string): { html: string } { const kind = mediaKind(rp); const enc = encodeURIComponent(rp); const rawUrl = `/file/raw?path=${enc}`; const dlUrl = `/file/dl?path=${enc}`; let player = ""; if (kind === "audio") player = ``; else if (kind === "video") player = ``; else if (kind === "image") player = `${escapeAttr(basename(rp))}`; else if (kind === "pdf") player = ``; const html = ` ${escapeHtml(basename(rp))} · 媒体
${escapeHtml(basename(rp))}
${breadcrumbHTML(rp, cfg)}
${player}
⬇ 下载
`; return { html }; } function buildDownloadView(cfg: Config, rp: string): { html: string } { const dlUrl = `/file/dl?path=${encodeURIComponent(rp)}`; const html = ` ${escapeHtml(basename(rp))} · 下载
${breadcrumbHTML(rp, cfg)}
📄
该文件无法在线预览(二进制)。
⬇ 下载 ${escapeHtml(basename(rp))}
`; return { html }; } export // Repo-relative refs (figures/x.png) in /file-rendered markdown 404 against /file // (and DOMPurify strips relative src entirely); rewriting at markdown-source level // yields rooted /file URLs that survive sanitization and re-pass the gate at request time. const MD_REF_RE = /(\]\()([^)\s]+)([)\s])/g; const SKIP_REF_RE = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i; export function rewriteRelativeMdRefs(md: string, dir: string): string { let out = ""; let last = 0; for (const m of md.matchAll(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm)) { out += rewriteRefs(md.slice(last, m.index!), dir) + m[0]; last = m.index! + m[0].length; } return out + rewriteRefs(md.slice(last), dir); } function rewriteRefs(text: string, dir: string): string { return text.replace(MD_REF_RE, (full: string, pre: string, ref: string, post: string) => { if (SKIP_REF_RE.test(ref) || ref.startsWith("/file")) return full; const abs = resolve(dir, ref); const media = /\.(?:png|jpe?g|gif|webp|bmp|svg|mp3|mp4|webm|ogg|wav|pdf)$/i.test(abs); const url = `${media ? "/file/raw" : "/file"}?path=${encodeURIComponent(abs)}`; return `${pre}${url}${post}`; }); } export function buildFileView( cfg: Config, rawPath: string, modeReq: string | undefined, orderReq: string | undefined, viewReq: string | undefined, sortReq: string | undefined, tdirReq: string | undefined ): { html: string } { const isLog = isLogPath(rawPath); const mode: "tail" | "head" = modeReq === "tail" || modeReq === "head" ? modeReq : isLog ? "tail" : "head"; const order: "asc" | "desc" = orderReq === "asc" || orderReq === "desc" ? orderReq : isLog ? "desc" : "asc"; const resolved = validatePath(cfg, rawPath); if (resolved.isDir) return buildDirView(cfg, resolved.rp, sortReq, tdirReq); if (mediaKind(rawPath)) return buildMediaView(cfg, resolved.rp); let chunk: FileChunk; try { chunk = readAllowedFile(cfg, rawPath, mode, order); } catch (e) { if (e instanceof FileViewError && e.status === 415) return buildDownloadView(cfg, resolved.rp); throw e; } const ext = (rawPath.split(".").pop() || "").toLowerCase(); const isMd = ext === "md" || ext === "markdown"; const mdRender = isMd && viewReq !== "raw"; const lang = extToLang(rawPath); const shownBytes = chunk.rows.reduce((s, r) => s + r.t.length + 1, 0); const fullText = chunk.rows.map((r) => r.t).join("\n"); const dir = dirname(rawPath); const body = mdRender ? `
${renderMarkdown(rewriteRelativeMdRefs(fullText, dir), dir)}
` : lang && shownBytes <= 100000 ? renderHighlighted(chunk, lang) : `
${chunk.rows.map((r) => lineRow(r.t, r.n)).join("")}
`; const enc = encodeURIComponent(rawPath); const ordQ = `&order=${order}`; const modeQ = `&mode=${mode}`; const html = ` ${escapeHtml(basename(rawPath))} · 文件
${escapeHtml(basename(rawPath))} ${isLog ? '(日志,默认最新在顶)' : ""}
${breadcrumbHTML(chunk.realPath, cfg)}
${escapeHtml(chunk.note)} 末尾 ${cfg.fileMaxLines} 开头 ${cfg.fileMaxLines} 倒排 正排 ${isMd ? ` 渲染 原文 ` : ""}
${body}
`; return { html }; } function lineRow(line: string, n: number): string { return `${n}${escapeHtml(line)}`; } function renderHighlighted(chunk: FileChunk, lang: string): string { const shown = chunk.rows.map((r) => r.t).join("\n"); const nums = chunk.rows.map((r) => r.n).join("\n"); return `
${escapeHtml(nums)}
${hljsHighlight(shown, lang)}
`; } function basename(p: string): string { const i = p.lastIndexOf("/"); return i >= 0 ? p.slice(i + 1) : p; }