Files
narl-workspace/web/src/lib/preview.ts
T
nvrl 1ae0665206 files: PDFs, text, code, tables and certificates open in place
The file pane decided what to show by MIME type, and uploads keep a
type only when it cannot execute, so a .md, a .json or a .pem arrived
as application/octet-stream and got "no preview". It now goes by name
first (preview.ts), and a file with a name it does not know has its
first 8 KiB looked at: no NUL bytes and valid UTF-8 means text.

- text and code: the first 256 KiB with line numbers, as before
- markdown: rendered through the same sanitiser as documents, with a
  source toggle
- CSV/TSV: a table of the first 500 rows, quotes respected
- PDF: the browser's own viewer in a frame. Only a PDF is served
  without the sandbox CSP (Chrome's viewer will not start under it),
  with frame-ancestors 'self' and X-Frame-Options SAMEORIGIN; the app
  CSP allows same-origin frames.
- certificates, keys, keystores: POST /api/nodes/{id}/inspect reads
  PEM (chains, keys, requests, public keys), DER and PKCS#12 on the
  server, in pure Rust, including the RC2/3DES keystores that
  openssl -legacy and older keychains write. It answers with subject,
  issuer, validity, key, names, usage and fingerprints, and pairs each
  private key with its certificate by public key. A password is asked
  for only when needed, used for that one answer and never stored;
  no key material is ever sent back. Sealed files are read through the
  vault, and only while it is open.

The tests read throwaway fixtures under src/routes/testdata, made with
the openssl commands listed beside them; .gitignore lets their .pem
files past a global *.pem rule.
2026-10-20 09:57:09 +02:00

85 lines
3.0 KiB
TypeScript

/** What the file pane shows a file as. */
export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'markdown' | 'table' | 'text' | 'cert' | 'none'
/*
* Uploads keep a MIME type only when it cannot execute, so a `.json` or a
* `.pem` is stored as `application/octet-stream`. The name says more.
*/
const TEXT = new Set(
(
'txt text log out err diff patch json jsonl ndjson yaml yml toml ini cfg conf env properties ' +
'xml html htm css scss js mjs cjs ts tsx jsx svelte vue rs go py rb php java kt swift c h cc cpp hpp cs ' +
'sh bash zsh fish ps1 bat sql graphql proto lua pl r tex bib srt vtt gitignore dockerfile makefile pub'
).split(' '),
)
const CERT = new Set(['pem', 'crt', 'cer', 'der', 'csr', 'key', 'p8', 'p12', 'pfx'])
/** `notes.md` → `md`; `Makefile` → `makefile`; a dotfile is its own name. */
function extension(name: string): string {
const lower = name.toLowerCase()
const dot = lower.lastIndexOf('.')
return dot === -1 ? lower : lower.slice(dot + 1)
}
/**
* By name first: `.ts` is TypeScript here, whatever the MIME tables say
* about MPEG transport streams. `none` may still turn out to be text — see
* `looksLikeText`.
*/
export function previewKind(name: string, mime: string): PreviewKind {
const ext = extension(name)
if (CERT.has(ext)) return 'cert'
if (ext === 'md' || ext === 'markdown') return 'markdown'
if (ext === 'csv' || ext === 'tsv') return 'table'
if (TEXT.has(ext)) return 'text'
const [type] = mime.split('/')
if (type === 'image' || type === 'video' || type === 'audio') return type
if (mime === 'application/pdf') return 'pdf'
if (mime === 'text/plain') return 'text'
return 'none'
}
/** No NUL bytes and valid UTF-8, a character cut off at the end aside. */
export function looksLikeText(bytes: Uint8Array): boolean {
if (bytes.includes(0)) return false
try {
new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })
return true
} catch {
return false
}
}
/** CSV or TSV into rows, quotes respected, at most `limit` of them. */
export function parseTable(text: string, separator: string, limit: number): string[][] {
const rows: string[][] = []
let row: string[] = []
let cell = ''
let quoted = false
for (let i = 0; i < text.length && rows.length < limit; i++) {
const c = text[i]
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
cell += '"'
i++
} else if (c === '"') quoted = false
else cell += c
} else if (c === '"' && cell === '') quoted = true
else if (c === separator) {
row.push(cell)
cell = ''
} else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++
row.push(cell)
rows.push(row)
row = []
cell = ''
} else cell += c
}
if ((cell !== '' || row.length > 0) && rows.length < limit) rows.push([...row, cell])
return rows
}
/** The separator of a table file, by its name. */
export const separatorOf = (name: string) => (extension(name) === 'tsv' ? '\t' : ',')