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.
This commit is contained in:
nvrl committed 2026-10-20 09:57:09 +02:00
1 parent 9749c500cf
commit 1ae0665206
21 files changed
+1571 -22

No files matched your search

+97 -11
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import CertView from './CertView.svelte'
import { hooks, runCommand, type ScrollAmount } from './commands'
import Icon from './Icon.svelte'
import { renderMarkdown } from './markdown'
import { looksLikeText, parseTable, previewKind, separatorOf } from './preview'
import { workspace } from './store.svelte'
import { formatBytes, formatStamp, relative, scrollPage } from './util'
@@ -8,27 +11,47 @@
const file = $derived(node.file!)
/* A sealed file cannot be fetched while the vault is shut. */
const locked = $derived(file.encrypted && !workspace.vault?.unlocked)
const kind = $derived(file.mime.split('/')[0])
const kind = $derived(previewKind(node.name, file.mime))
/* What the browser can show by itself; everything else is a download. */
const viewable = $derived(file.mime !== 'application/octet-stream')
/*
* Plain text is shown inline, but only its beginning: a log file can run to
* gigabytes, and the download has the rest.
* Text is shown inline, but only its beginning: a log file can run to
* gigabytes, and the download has the rest. A file nothing is known about
* gets its first few kilobytes looked at, and is shown as text if that is
* what they are.
*/
const TEXT_PREVIEW = 256 * 1024
const SNIFF = 8 * 1024
let text = $state<string | null>(null)
/* Markdown is read rendered, unless asked for its source. */
let source = $state(false)
$effect(() => {
text = null
if (file.mime !== 'text/plain' || locked) return
source = false
if (locked || file.size === 0) return
if (kind !== 'text' && kind !== 'markdown' && kind !== 'table' && kind !== 'none') return
const url = file.url
void head(url, TEXT_PREVIEW).then((body) => {
if (body !== null && url === file.url) text = body
})
const sniff = kind === 'none'
void (async () => {
if (sniff) {
const start = await head(url, SNIFF)
if (!start || !looksLikeText(start)) return
}
const bytes = await head(url, TEXT_PREVIEW)
if (bytes !== null && url === file.url) text = new TextDecoder().decode(bytes)
})()
})
/** The first `limit` bytes of a download, as text; the rest is never fetched. */
async function head(url: string, limit: number): Promise<string | null> {
const lines = $derived(text === null ? [] : text.replace(/\n$/, '').split('\n'))
const html = $derived(kind === 'markdown' && text !== null ? renderMarkdown(text) : '')
const TABLE_ROWS = 500
const table = $derived(kind === 'table' && text !== null ? parseTable(text, separatorOf(node.name), TABLE_ROWS) : [])
/** The first `limit` bytes of a download; the rest is never fetched. */
async function head(url: string, limit: number): Promise<Uint8Array | null> {
const response = await fetch(url)
if (!response.ok || !response.body) return null
const reader = response.body.getReader()
@@ -42,7 +65,7 @@
size += take.length
}
void reader.cancel()
return new TextDecoder().decode(bytes.subarray(0, size))
return bytes.subarray(0, size)
}
let reader = $state<HTMLElement>()
@@ -89,6 +112,9 @@
{#if locked}
<button onclick={() => runCommand('unlock')}>unlock</button>
{:else}
{#if kind === 'markdown' && text !== null}
<button onclick={() => (source = !source)}>{source ? 'rendered' : 'source'}</button>
{/if}
{#if viewable}
<a class="button" href={file.url} target="_blank" rel="noopener">open</a>
{/if}
@@ -118,8 +144,38 @@
<video src={file.url} controls playsinline preload="metadata"></video>
{:else if kind === 'audio'}
<audio src={file.url} controls preload="metadata"></audio>
{:else if kind === 'pdf'}
<iframe class="pdf" src={file.url} title={node.name}></iframe>
{:else if kind === 'cert'}
<CertView id={node.id} name={node.name} />
{:else if text !== null}
<pre>{text}</pre>
{#if kind === 'markdown' && !source}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
<article class="md">{@html html}</article>
{:else if kind === 'table'}
<div class="table">
<table>
<thead>
<tr>
{#each table[0] ?? [] as cell, j (j)}<th>{cell}</th>{/each}
</tr>
</thead>
<tbody>
{#each table.slice(1) as row, i (i)}
<tr>
{#each row as cell, j (j)}<td>{cell}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{#if table.length === TABLE_ROWS}
<p class="muted">the first {TABLE_ROWS} rows — download it for the rest.</p>
{/if}
{:else}
<pre class="lines">{#each lines as line, i (i)}<span>{line}
</span>{/each}</pre>
{/if}
{#if file.size > TEXT_PREVIEW}
<p class="muted">the first {formatBytes(TEXT_PREVIEW)} of {formatBytes(file.size)} — download it for the rest.</p>
{/if}
@@ -215,6 +271,36 @@
overflow-wrap: anywhere;
}
.lines { counter-reset: line; }
.lines span::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 4ch;
margin-right: 1.5ch;
color: var(--muted);
text-align: right;
user-select: none;
}
.pdf {
display: block;
width: 100%;
height: 80vh;
border: 1px solid var(--rim);
border-radius: var(--radius-m);
background: var(--surface);
}
.table { overflow-x: auto; }
table { border-collapse: collapse; font-size: 12px; }
th, td {
padding: 4px 10px;
border: 1px solid var(--rim);
text-align: left;
vertical-align: top;
white-space: pre-wrap;
}
th { color: var(--light); background: var(--surface); }
.light { color: var(--light); }
.sealed { display: flex; align-items: center; gap: 8px; color: var(--subtle); }