mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
9749c500cf
commit
1ae0665206
21 files changed
+1571
-22
No files matched your search
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* A certificate, key or keystore, as the server reads it. A password is
|
||||
* asked for only when the file needs one, and goes with that one request.
|
||||
*/
|
||||
import { api, type CertItem, type Inspection } from './api'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatDay } from './util'
|
||||
|
||||
let { id, name }: { id: number; name: string } = $props()
|
||||
|
||||
let inspection = $state<Inspection | null>(null)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
async function load(password?: string) {
|
||||
try {
|
||||
inspection = await api.inspect(id, password)
|
||||
error = null
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
async function unlock() {
|
||||
const password = await workspace.askSecret(`password for ${name}`)
|
||||
if (password === null) return
|
||||
await load(password)
|
||||
if (error) workspace.say(error, 'error')
|
||||
}
|
||||
|
||||
function expiry(seconds: number): string {
|
||||
const days = Math.round((seconds - Date.now() / 1000) / 86400)
|
||||
if (days < 0) return `${formatDay(seconds)} — expired`
|
||||
return `${formatDay(seconds)} — ${days} day${days === 1 ? '' : 's'} left`
|
||||
}
|
||||
|
||||
const expired = (item: CertItem) => item.kind === 'certificate' && item.not_after < Date.now() / 1000
|
||||
|
||||
const title = (item: CertItem) =>
|
||||
({ certificate: 'certificate', request: 'signing request', private_key: 'private key', public_key: 'public key' })[
|
||||
item.kind
|
||||
]
|
||||
</script>
|
||||
|
||||
{#if inspection}
|
||||
{#if inspection.needs_password}
|
||||
<p class="locked">
|
||||
protected by a password — <button onclick={unlock}>enter it</button> to read {inspection.items.length ? 'the rest' : 'it'}.
|
||||
</p>
|
||||
{/if}
|
||||
{#each inspection.items as item, i (i)}
|
||||
<section class="card" class:expired={expired(item)}>
|
||||
<h2 class="label">
|
||||
<span class="index">{i + 1}</span>
|
||||
{title(item)}
|
||||
{#if item.kind === 'certificate' || item.kind === 'private_key'}
|
||||
{#if item.label}<span class="muted">{item.label}</span>{/if}
|
||||
{/if}
|
||||
</h2>
|
||||
<dl>
|
||||
{#if item.kind === 'certificate'}
|
||||
<dt>subject</dt>
|
||||
<dd class="light">{item.subject}</dd>
|
||||
<dt>issuer</dt>
|
||||
<dd>{item.issuer === item.subject ? 'self-signed' : item.issuer}</dd>
|
||||
<dt>valid from</dt>
|
||||
<dd>{formatDay(item.not_before)}</dd>
|
||||
<dt>valid until</dt>
|
||||
<dd class:alarm={expired(item)}>{expiry(item.not_after)}</dd>
|
||||
<dt>key</dt>
|
||||
<dd>{item.key}</dd>
|
||||
{#if item.names.length}
|
||||
<dt>names</dt>
|
||||
<dd>{item.names.join(', ')}</dd>
|
||||
{/if}
|
||||
{#if item.usage.length}
|
||||
<dt>usage</dt>
|
||||
<dd>{item.usage.join(', ')}</dd>
|
||||
{/if}
|
||||
{#if item.ca}
|
||||
<dt>authority</dt>
|
||||
<dd>can sign other certificates</dd>
|
||||
{/if}
|
||||
<dt>serial</dt>
|
||||
<dd class="mono">{item.serial}</dd>
|
||||
<dt>sha-256</dt>
|
||||
<dd class="mono">{item.sha256}</dd>
|
||||
<dt>sha-1</dt>
|
||||
<dd class="mono">{item.sha1}</dd>
|
||||
{:else if item.kind === 'request'}
|
||||
<dt>subject</dt>
|
||||
<dd class="light">{item.subject}</dd>
|
||||
<dt>key</dt>
|
||||
<dd>{item.key}</dd>
|
||||
{#if item.names.length}
|
||||
<dt>names</dt>
|
||||
<dd>{item.names.join(', ')}</dd>
|
||||
{/if}
|
||||
{:else if item.kind === 'private_key'}
|
||||
<dt>key</dt>
|
||||
<dd>{item.key ?? 'encrypted'}</dd>
|
||||
{#if item.key}
|
||||
<dt>certificate</dt>
|
||||
<dd>{item.matches === null ? 'none here matches it' : `belongs to ${item.matches + 1}`}</dd>
|
||||
{/if}
|
||||
{:else}
|
||||
<dt>key</dt>
|
||||
<dd>{item.key}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</section>
|
||||
{/each}
|
||||
{:else if error}
|
||||
<p class="muted">{error}</p>
|
||||
{:else}
|
||||
<p class="muted">reading…</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.card {
|
||||
max-width: 52rem;
|
||||
margin: 0 0 14px;
|
||||
padding: 12px 16px 14px;
|
||||
border: 1px solid var(--rim);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--surface);
|
||||
}
|
||||
.card.expired { border-color: var(--alarm); }
|
||||
h2 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.index { color: var(--muted); }
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 4px 16px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; font-size: 11px; }
|
||||
dd { margin: 0; color: var(--fg); overflow-wrap: anywhere; }
|
||||
.light { color: var(--light); }
|
||||
.alarm { color: var(--alarm); }
|
||||
.mono { font-family: var(--font-mono); font-size: 11px; }
|
||||
.locked { color: var(--subtle); }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
dl { grid-template-columns: 1fr; gap: 2px; }
|
||||
dd { margin-bottom: 6px; }
|
||||
}
|
||||
</style>
|
||||
+97
-11
@@ -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); }
|
||||
|
||||
|
||||
@@ -141,7 +141,8 @@
|
||||
<footer>
|
||||
inside the editor the full vim keymap is live — hjkl, w/b, dd, ciw, visual mode, macros, and
|
||||
<span class="key">:w</span> to write. drop or paste an image, video or audio file into the
|
||||
editor to attach it. <span class="key">:12</span> jumps straight to entry 12. yanks and
|
||||
editor to attach it. a file opens in place — pdf, text and code, markdown, csv, and
|
||||
certificates and keystores, which ask for their password. <span class="key">:12</span> jumps straight to entry 12. yanks and
|
||||
deletes are copied to the system clipboard —
|
||||
<span class="key">:set noclipboard</span> keeps them in vim's registers.
|
||||
</footer>
|
||||
|
||||
@@ -141,6 +141,42 @@ export interface MediaFile {
|
||||
file_node: number | null
|
||||
}
|
||||
|
||||
/** One thing a certificate, key or keystore file holds, as `/inspect` describes it. */
|
||||
export type CertItem =
|
||||
| {
|
||||
kind: 'certificate'
|
||||
subject: string
|
||||
issuer: string
|
||||
serial: string
|
||||
not_before: number
|
||||
not_after: number
|
||||
/** `RSA 2048`, `EC P-256`, `Ed25519`. */
|
||||
key: string
|
||||
names: string[]
|
||||
usage: string[]
|
||||
ca: boolean
|
||||
sha256: string
|
||||
sha1: string
|
||||
label: string | null
|
||||
}
|
||||
| { kind: 'request'; subject: string; key: string; names: string[] }
|
||||
| {
|
||||
kind: 'private_key'
|
||||
/** Null while it is still encrypted. */
|
||||
key: string | null
|
||||
encrypted: boolean
|
||||
/** The index of its certificate among the items. */
|
||||
matches: number | null
|
||||
label: string | null
|
||||
}
|
||||
| { kind: 'public_key'; key: string }
|
||||
|
||||
export interface Inspection {
|
||||
items: CertItem[]
|
||||
/** Something in the file is behind a password that was not given. */
|
||||
needs_password: boolean
|
||||
}
|
||||
|
||||
/** The vault, as `/api/vault` reports it. */
|
||||
export interface VaultStatus {
|
||||
configured: boolean
|
||||
@@ -403,6 +439,10 @@ export const api = {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
/** What a certificate, key or keystore holds; the password opens it for this answer only. */
|
||||
inspect: (id: number, password?: string) =>
|
||||
request<Inspection>(`/nodes/${id}/inspect`, { method: 'POST', body: body({ password }) }),
|
||||
|
||||
backupStatus: () => request<BackupStatus>('/backup'),
|
||||
|
||||
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** 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' : ',')
|
||||
Reference in new issue
Block a user