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

+4
View File
@@ -14,3 +14,7 @@
# host-specific compose overlay # host-specific compose overlay
compose.override.yml compose.override.yml
# Throwaway certificates the inspect tests read; a global *.pem rule must not
# keep them out.
!/src/routes/testdata/*.pem
Generated
+249 -2
View File
@@ -154,6 +154,45 @@ dependencies = [
"password-hash 0.6.1", "password-hash 0.6.1",
] ]
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 2.0.20",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -772,6 +811,18 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "cms"
version = "0.3.0-pre.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758d5272932ff167e96ec9641a04d96ab1901cccc5ea9f47c15b4cbaa6f9ea53"
dependencies = [
"const-oid 0.10.2",
"der 0.8.2",
"spki 0.8.0",
"x509-cert",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.8" version = "4.6.8"
@@ -1186,10 +1237,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
dependencies = [ dependencies = [
"const-oid 0.10.2", "const-oid 0.10.2",
"der_derive",
"flagset",
"pem-rfc7468 1.0.0", "pem-rfc7468 1.0.0",
"zeroize", "zeroize",
] ]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom 7.1.3",
"num-bigint 0.4.8",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "der_derive"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "deranged"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]] [[package]]
name = "derive_arbitrary" name = "derive_arbitrary"
version = "1.4.2" version = "1.4.2"
@@ -1662,6 +1746,12 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.10" version = "1.1.10"
@@ -2520,7 +2610,7 @@ dependencies = [
"httpdate", "httpdate",
"idna", "idna",
"mime", "mime",
"nom", "nom 8.0.0",
"percent-encoding", "percent-encoding",
"quoted_printable", "quoted_printable",
"rustls", "rustls",
@@ -2685,6 +2775,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.9.1" version = "0.9.1"
@@ -2766,6 +2862,8 @@ dependencies = [
"keyring", "keyring",
"lettre", "lettre",
"mime_guess", "mime_guess",
"p12-keystore",
"pkcs8 0.11.0",
"proton-drive-rs", "proton-drive-rs",
"proton-sdk", "proton-sdk",
"rand 0.10.2", "rand 0.10.2",
@@ -2775,8 +2873,10 @@ dependencies = [
"russh", "russh",
"russh-sftp", "russh-sftp",
"rust-embed", "rust-embed",
"sec1 0.8.1",
"serde", "serde",
"serde_json", "serde_json",
"sha1 0.10.7",
"sha2 0.10.9", "sha2 0.10.9",
"sqlx", "sqlx",
"subtle", "subtle",
@@ -2788,6 +2888,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"x509-parser",
"zeroize", "zeroize",
"zip", "zip",
] ]
@@ -2817,6 +2918,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "8.0.0" version = "8.0.0"
@@ -2897,6 +3008,12 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.47" version = "0.1.47"
@@ -2970,6 +3087,15 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -3004,6 +3130,29 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "p12-keystore"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c2ed4fa0ec7ec58b657d4184680c57da9e203e97771d29a566028869a5193e0"
dependencies = [
"cbc 0.2.1",
"cms",
"der 0.8.2",
"des 0.9.0",
"hex",
"hmac 0.13.0",
"pkcs12",
"pkcs5",
"pkcs8 0.11.0",
"rand 0.10.2",
"rc2",
"sha1 0.11.0",
"sha2 0.11.0",
"thiserror 2.0.20",
"x509-parser",
]
[[package]] [[package]]
name = "p256" name = "p256"
version = "0.13.2" version = "0.13.2"
@@ -3231,7 +3380,7 @@ dependencies = [
"log", "log",
"md-5", "md-5",
"memchr", "memchr",
"nom", "nom 8.0.0",
"num-bigint-dig", "num-bigint-dig",
"num-traits", "num-traits",
"num_enum", "num_enum",
@@ -3322,6 +3471,21 @@ dependencies = [
"spki 0.8.0", "spki 0.8.0",
] ]
[[package]]
name = "pkcs12"
version = "0.2.0-pre.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8059bd79345d1d1c125656025814704bab5cdd204b1cefd5fab59b0fdd7476d2"
dependencies = [
"cms",
"const-oid 0.10.2",
"der 0.8.2",
"digest 0.11.3",
"spki 0.8.0",
"x509-cert",
"zeroize",
]
[[package]] [[package]]
name = "pkcs5" name = "pkcs5"
version = "0.8.1" version = "0.8.1"
@@ -3332,6 +3496,7 @@ dependencies = [
"aes-gcm 0.11.1", "aes-gcm 0.11.1",
"cbc 0.2.1", "cbc 0.2.1",
"der 0.8.2", "der 0.8.2",
"des 0.9.0",
"pbkdf2", "pbkdf2",
"rand_core 0.10.1", "rand_core 0.10.1",
"scrypt", "scrypt",
@@ -3431,6 +3596,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -3695,6 +3866,15 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "rc2"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ceda21af1ae61033b63175653a1af86cae399d79cd03ca80ba347eb3a6c4a7fe"
dependencies = [
"cipher 0.5.2",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -4070,6 +4250,15 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom 7.1.3",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@@ -5034,6 +5223,36 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.4" version = "0.8.4"
@@ -5953,6 +6172,34 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "x509-cert"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b"
dependencies = [
"const-oid 0.10.2",
"der 0.8.2",
"spki 0.8.0",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom 7.1.3",
"oid-registry",
"rusticata-macros",
"thiserror 2.0.20",
"time",
]
[[package]] [[package]]
name = "xdg-home" name = "xdg-home"
version = "1.3.0" version = "1.3.0"
+9
View File
@@ -64,6 +64,15 @@ russh-sftp = "3"
# speaks rand_core 0.10, one major version past the `rand` used everywhere else. # speaks rand_core 0.10, one major version past the `rand` used everywhere else.
rand10 = { package = "rand", version = "0.10" } rand10 = { package = "rand", version = "0.10" }
# Reading certificates, keys and keystores for the file view, in pure Rust —
# there is no OpenSSL to lean on. p12-keystore brings RC2 and 3DES for the
# keystores older tools still export; pkcs8 opens encrypted PKCS#8 keys.
x509-parser = "0.18"
p12-keystore = "0.4"
pkcs8 = { version = "0.11", features = ["encryption", "3des", "std"] }
sec1 = { version = "0.8", features = ["der"] }
sha1 = "0.10"
[dev-dependencies] [dev-dependencies]
# A paused clock, so tests of timed loops do not wait in real time. # A paused clock, so tests of timed loops do not wait in real time.
tokio = { version = "1", features = ["test-util"] } tokio = { version = "1", features = ["test-util"] }
+20 -3
View File
@@ -11,7 +11,8 @@ one binary. It was called `narl-diary` until it grew everything but the diary.
- A kanban board per space, so what is open at work is not mixed up with uni. - A kanban board per space, so what is open at work is not mixed up with uni.
- Drag, drop or paste images, video and audio straight into an entry. - Drag, drop or paste images, video and audio straight into an entry.
- Files of any kind — PDFs, archives, recordings — live in the tree beside the - Files of any kind — PDFs, archives, recordings — live in the tree beside the
notes about them, streamed to disk however large. notes about them, streamed to disk however large — and PDFs, text, code,
tables and certificates open in place.
- Share any document behind an unguessable link — revocable at any time. - Share any document behind an unguessable link — revocable at any time.
- A vault: files sealed with AES-256-GCM under a password that is never stored, - A vault: files sealed with AES-256-GCM under a password that is never stored,
readable only while it is unlocked. readable only while it is unlocked.
@@ -237,8 +238,23 @@ the tree is never one of them: it is deleted from its folder, not from
## Files ## Files
A file is a node like a document, only with bytes instead of text: it sits in A file is a node like a document, only with bytes instead of text: it sits in
a folder, shows its size in the list, and `Enter` opens it — images, video, a folder, shows its size in the list, and `Enter` opens it. What is shown in
audio and plain text are shown in place, anything else is a download. `:name` place:
- images, video and audio;
- PDFs, in the browser's own viewer;
- text and source code of any kind, recognised by its name or, failing that, by
its first few kilobytes, with line numbers and the first 256 KiB of it;
- markdown, rendered, with `source` to see it as written;
- CSV and TSV, as a table of the first 500 rows;
- certificates, keys and keystores — `.pem`, `.crt`, `.cer`, `.der`, `.csr`,
`.key`, `.p8`, `.p12`, `.pfx` — read on the server: subject, issuer,
validity, key, names, usage and fingerprints, and which key belongs to which
certificate. A `.p12` or an encrypted key asks for its password, which opens
it for that one look and is never stored. Nothing of a private key is ever
sent back but its algorithm and size.
Anything else is a download. `:name`
renames it, and the download is called whatever it is called now; `:mv`, `m` renames it, and the download is called whatever it is called now; `:mv`, `m`
and `p` move it; `x` deletes it. `:upload` picks files for the folder being and `p` move it; `x` deletes it. `:upload` picks files for the folder being
browsed, or drop them onto the list. Each upload is written to disk as it browsed, or drop them onto the list. Each upload is written to disk as it
@@ -532,6 +548,7 @@ sealed or not.
| `GET`/`DELETE` | `/api/media/{id}` | serve (with `Range`) / delete a file nothing in the tree owns | | `GET`/`DELETE` | `/api/media/{id}` | serve (with `Range`) / delete a file nothing in the tree owns |
| `GET` | `/api/spaces/overview` | every space with what was last written in it | | `GET` | `/api/spaces/overview` | every space with what was last written in it |
| `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) | | `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) |
| `POST` | `/api/nodes/{id}/inspect` | what a certificate, key or keystore file holds (`{password}` opens a protected one) |
| `POST` | `/api/nodes/{id}/encrypt`, `/decrypt` | seal a file, or every file under a folder, or take it out | | `POST` | `/api/nodes/{id}/encrypt`, `/decrypt` | seal a file, or every file under a folder, or take it out |
| `GET`/`POST`/`PUT` | `/api/vault` | status / set the password once / change it | | `GET`/`POST`/`PUT` | `/api/vault` | status / set the password once / change it |
| `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault | | `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault |
+1 -1
View File
@@ -371,7 +371,7 @@ fn compressible(
/// runtime; scripts stay confined to the bundled files. /// runtime; scripts stay confined to the bundled files.
const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \ const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \
style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; \ style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; \
object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
type HeaderLayer = SetResponseHeaderLayer<HeaderValue>; type HeaderLayer = SetResponseHeaderLayer<HeaderValue>;
+775
View File
@@ -0,0 +1,775 @@
//! `POST /api/nodes/{id}/inspect`: what a certificate, key or keystore file
//! holds, read on the server so the browser needs no ASN.1 of its own.
//!
//! PEM with any mix of blocks (a chain, a certificate with its key), DER
//! certificates, requests and keys, and PKCS#12 keystores — the legacy RC2 and
//! 3DES ones a keychain exports as well as AES. A password opens a keystore or
//! an encrypted key for this one answer; it is never stored and never logged.
//!
//! The answer describes keys — algorithm, size, which certificate each belongs
//! to — and never carries key material itself.
use std::io::Read;
use axum::{
extract::{Path, State},
Json,
};
use p12_keystore::{error::Error as P12Error, Pkcs12Archive};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use sqlx::Row;
use x509_parser::{
certification_request::X509CertificationRequest,
der_parser::der::parse_der_sequence,
extensions::{GeneralName, ParsedExtension},
pem::Pem,
prelude::{FromDer, X509Certificate},
public_key::{PublicKey, RSAPublicKey},
x509::SubjectPublicKeyInfo,
};
use zeroize::Zeroizing;
use crate::{
auth::Session,
error::{AppError, AppResult},
state::AppState,
vault::{self, Password},
};
/// Certificates and keys are kilobytes. Anything past this is not one, and is
/// not worth reading into memory to find that out.
const MAX_INSPECT: u64 = 1024 * 1024;
#[derive(Deserialize, Default)]
pub struct InspectInput {
#[serde(default)]
pub password: Option<Password>,
}
#[derive(Serialize, Debug, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Item {
Certificate {
subject: String,
issuer: String,
serial: String,
/// Unix seconds.
not_before: i64,
not_after: i64,
/// `RSA 2048`, `EC P-256`, `Ed25519`.
key: String,
/// Subject alternative names.
names: Vec<String>,
/// Key usage and extended key usage, together.
usage: Vec<String>,
ca: bool,
sha256: String,
sha1: String,
/// The friendly name a keystore gave it.
label: Option<String>,
},
Request {
subject: String,
key: String,
names: Vec<String>,
},
PrivateKey {
/// None while the key is still encrypted.
key: Option<String>,
/// Whether it came under a password.
encrypted: bool,
/// The index of the certificate in `items` with the same public key.
matches: Option<usize>,
label: Option<String>,
},
PublicKey {
key: String,
},
}
#[derive(Serialize, Debug, Default)]
pub struct Inspection {
pub items: Vec<Item>,
/// Something here is behind a password that was not given.
pub needs_password: bool,
}
#[derive(Debug, PartialEq)]
pub enum Failure {
Unreadable,
WrongPassword,
}
pub async fn inspect(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<InspectInput>,
) -> AppResult<Json<Inspection>> {
let row = sqlx::query(
"SELECT m.id, m.size, m.encrypted FROM nodes n JOIN media m ON m.id = n.media_id
WHERE n.id = ?1 AND n.kind = 'file'",
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let media_id: String = row.get("id");
let size: i64 = row.get("size");
let encrypted: bool = row.get("encrypted");
if size.max(0) as u64 > MAX_INSPECT {
return Err(AppError::BadRequest(
"too large to be a certificate or a key".into(),
));
}
let key = match encrypted {
true => Some(state.vault.key().ok_or(AppError::Locked)?),
false => None,
};
let path = state.config.uploads_dir().join(&media_id);
let outcome = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
let mut bytes = Zeroizing::new(Vec::new());
match key {
Some(key) => {
vault::Reader::open(&key, &media_id, &path)?
.take(MAX_INSPECT)
.read_to_end(&mut bytes)?;
}
None => {
std::fs::File::open(&path)?
.take(MAX_INSPECT)
.read_to_end(&mut bytes)?;
}
}
Ok(inspect_bytes(
&bytes,
input.password.as_ref().map(|p| p.as_str()),
))
})
.await
.map_err(|e| AppError::Internal(e.into()))??;
match outcome {
Ok(inspection) => Ok(Json(inspection)),
Err(Failure::WrongPassword) => Err(AppError::BadRequest("wrong password".into())),
Err(Failure::Unreadable) => Err(AppError::BadRequest(
"not a certificate, key or keystore this can read".into(),
)),
}
}
/// Everything the file holds, in the order it holds it.
pub fn inspect_bytes(bytes: &[u8], password: Option<&str>) -> Result<Inspection, Failure> {
let mut found = Found::default();
if bytes.windows(11).any(|w| w == b"-----BEGIN ") {
for pem in Pem::iter_from_buffer(bytes).map_while(Result::ok) {
found.pem(&pem, password)?;
}
} else {
found.der(bytes, password)?;
}
if found.items.is_empty() && !found.needs_password {
return Err(Failure::Unreadable);
}
Ok(found.finish())
}
/// The items so far, each with the public key it stands for where one is
/// known, so a private key can be paired with its certificate at the end.
#[derive(Default)]
struct Found {
items: Vec<Item>,
identities: Vec<Option<Vec<u8>>>,
needs_password: bool,
}
impl Found {
fn push(&mut self, item: Item, identity: Option<Vec<u8>>) {
self.items.push(item);
self.identities.push(identity);
}
fn finish(mut self) -> Inspection {
let certificates: Vec<(usize, Vec<u8>)> = self
.items
.iter()
.zip(&self.identities)
.enumerate()
.filter_map(|(at, (item, identity))| match (item, identity) {
(Item::Certificate { .. }, Some(identity)) => Some((at, identity.clone())),
_ => None,
})
.collect();
for (item, identity) in self.items.iter_mut().zip(&self.identities) {
if let (Item::PrivateKey { matches, .. }, Some(identity)) = (item, identity) {
*matches = certificates
.iter()
.find(|(_, other)| other == identity)
.map(|(at, _)| *at);
}
}
Inspection {
items: self.items,
needs_password: self.needs_password,
}
}
fn pem(&mut self, pem: &Pem, password: Option<&str>) -> Result<(), Failure> {
let der = &pem.contents;
match pem.label.as_str() {
"CERTIFICATE" | "X509 CERTIFICATE" | "TRUSTED CERTIFICATE" => {
self.certificate(der, None);
}
"CERTIFICATE REQUEST" | "NEW CERTIFICATE REQUEST" => {
self.request(der);
}
"PRIVATE KEY" => {
self.private_key(der, false, None);
}
"ENCRYPTED PRIVATE KEY" => self.encrypted_key(der, password)?,
"RSA PRIVATE KEY" => {
// An old OpenSSL key under a password is this label with
// headers; its body is then not an RSAPrivateKey at all.
let (key, identity) = match rsa_modulus(der) {
Some(modulus) => (Some(rsa_name(modulus)), Some(trim(modulus).to_vec())),
None => (None, None),
};
let encrypted = key.is_none();
self.push(
Item::PrivateKey {
key,
encrypted,
matches: None,
label: None,
},
identity,
);
}
"EC PRIVATE KEY" => {
if let Ok(ec) = sec1::EcPrivateKey::try_from(der.as_slice()) {
let curve = ec
.parameters
.and_then(|p| p.named_curve())
.map(|oid| curve_name(&oid.to_string()));
self.push(
Item::PrivateKey {
key: Some(ec_name(curve)),
encrypted: false,
matches: None,
label: None,
},
ec.public_key.map(<[u8]>::to_vec),
);
}
}
"PUBLIC KEY" => {
if let Ok((_, spki)) = SubjectPublicKeyInfo::from_der(der) {
let (key, identity) = describe(&spki);
self.push(Item::PublicKey { key }, identity);
}
}
"RSA PUBLIC KEY" => {
if let Ok((_, rsa)) = RSAPublicKey::from_der(der) {
self.push(
Item::PublicKey {
key: format!("RSA {}", rsa.key_size()),
},
Some(trim(rsa.modulus).to_vec()),
);
}
}
_ => {}
}
Ok(())
}
/// A DER file says nothing about what it is, so each reading is tried in
/// turn, the strictest first.
fn der(&mut self, der: &[u8], password: Option<&str>) -> Result<(), Failure> {
if self.certificate(der, None) || self.request(der) {
return Ok(());
}
if looks_like_pkcs12(der) {
return self.pkcs12(der, password);
}
if self.private_key(der, false, None) {
return Ok(());
}
if pkcs8::EncryptedPrivateKeyInfoRef::try_from(der).is_ok() {
return self.encrypted_key(der, password);
}
if let Ok((_, spki)) = SubjectPublicKeyInfo::from_der(der) {
let (key, identity) = describe(&spki);
self.push(Item::PublicKey { key }, identity);
}
Ok(())
}
fn certificate(&mut self, der: &[u8], label: Option<String>) -> bool {
let Ok((_, cert)) = X509Certificate::from_der(der) else {
return false;
};
let (key, identity) = describe(cert.public_key());
let names = cert
.subject_alternative_name()
.ok()
.flatten()
.map(|san| san.value.general_names.iter().map(name).collect())
.unwrap_or_default();
let mut usage: Vec<String> = cert
.key_usage()
.ok()
.flatten()
.map(|ku| ku.value.to_string())
.into_iter()
.flat_map(|list| list.split(", ").map(str::to_string).collect::<Vec<_>>())
.filter(|s| !s.is_empty())
.collect();
if let Ok(Some(eku)) = cert.extended_key_usage() {
let eku = eku.value;
for (on, what) in [
(eku.any, "Any Purpose"),
(eku.server_auth, "Server Authentication"),
(eku.client_auth, "Client Authentication"),
(eku.code_signing, "Code Signing"),
(eku.email_protection, "Email Protection"),
(eku.time_stamping, "Time Stamping"),
(eku.ocsp_signing, "OCSP Signing"),
] {
if on {
usage.push(what.into());
}
}
usage.extend(eku.other.iter().map(|oid| oid.to_id_string()));
}
let ca = cert
.basic_constraints()
.ok()
.flatten()
.is_some_and(|bc| bc.value.ca);
self.push(
Item::Certificate {
subject: cert.subject().to_string(),
issuer: cert.issuer().to_string(),
serial: cert.raw_serial_as_string(),
not_before: cert.validity().not_before.timestamp(),
not_after: cert.validity().not_after.timestamp(),
key,
names,
usage,
ca,
sha256: fingerprint(&Sha256::digest(der)),
sha1: fingerprint(&Sha1::digest(der)),
label,
},
identity,
);
true
}
fn request(&mut self, der: &[u8]) -> bool {
let Ok((_, csr)) = X509CertificationRequest::from_der(der) else {
return false;
};
let info = &csr.certification_request_info;
let (key, _) = describe(&info.subject_pki);
let names = csr
.requested_extensions()
.into_iter()
.flatten()
.filter_map(|ext| match ext {
ParsedExtension::SubjectAlternativeName(san) => Some(san),
_ => None,
})
.flat_map(|san| san.general_names.iter().map(name))
.collect();
self.push(
Item::Request {
subject: info.subject.to_string(),
key,
names,
},
None,
);
true
}
/// A PKCS#8 key, in the clear by now.
fn private_key(&mut self, der: &[u8], encrypted: bool, label: Option<String>) -> bool {
let Ok(info) = pkcs8::PrivateKeyInfoRef::try_from(der) else {
return false;
};
let body = info.private_key.as_bytes();
let (key, identity) = match info.algorithm.oid.to_string().as_str() {
RSA => match rsa_modulus(body) {
Some(modulus) => (rsa_name(modulus), Some(trim(modulus).to_vec())),
None => ("RSA".into(), None),
},
EC => {
let curve = info
.algorithm
.parameters
.and_then(|p| p.decode_as::<pkcs8::ObjectIdentifier>().ok())
.map(|oid| curve_name(&oid.to_string()));
let public = sec1::EcPrivateKey::try_from(body)
.ok()
.and_then(|ec| ec.public_key.map(<[u8]>::to_vec));
(ec_name(curve), public)
}
ED25519 => (
"Ed25519".into(),
info.public_key
.and_then(|k| k.as_bytes().map(<[u8]>::to_vec)),
),
ED448 => ("Ed448".into(), None),
other => (other.to_string(), None),
};
self.push(
Item::PrivateKey {
key: Some(key),
encrypted,
matches: None,
label,
},
identity,
);
true
}
fn encrypted_key(&mut self, der: &[u8], password: Option<&str>) -> Result<(), Failure> {
let Ok(info) = pkcs8::EncryptedPrivateKeyInfoRef::try_from(der) else {
return Ok(());
};
let Some(password) = password else {
self.needs_password = true;
self.push(
Item::PrivateKey {
key: None,
encrypted: true,
matches: None,
label: None,
},
None,
);
return Ok(());
};
let plain = info.decrypt(password).map_err(|_| Failure::WrongPassword)?;
if !self.private_key(plain.as_bytes(), true, None) {
return Err(Failure::WrongPassword);
}
Ok(())
}
/// A keystore is tried with no password first, as plenty are exported
/// with an empty one.
fn pkcs12(&mut self, der: &[u8], password: Option<&str>) -> Result<(), Failure> {
let archive = match Pkcs12Archive::from_pkcs12(der, password.unwrap_or("")) {
Ok(archive) => archive,
Err(e) if password_problem(&e) => {
if password.is_some() {
return Err(Failure::WrongPassword);
}
self.needs_password = true;
return Ok(());
}
Err(e) => {
tracing::debug!("unreadable keystore: {e}");
return Err(Failure::Unreadable);
}
};
for bag in &archive.certs {
self.certificate(bag.cert.as_der(), bag.friendly_name.clone());
}
for bag in &archive.keys {
let der = Zeroizing::new(bag.key.as_der().to_vec());
self.private_key(&der, true, bag.friendly_name.clone());
}
Ok(())
}
}
/// Whatever goes wrong once the structure has been recognised: a bad MAC, or
/// bags that decrypt to garbage.
fn password_problem(e: &P12Error) -> bool {
!matches!(
e,
P12Error::InvalidVersion | P12Error::UnsupportedContentType | P12Error::IoError(_)
)
}
/// `PFX ::= SEQUENCE { version INTEGER (3), authSafe ContentInfo, … }`, with
/// the ContentInfo of type `data` — enough to tell a keystore from any other
/// DER without its password.
fn looks_like_pkcs12(der: &[u8]) -> bool {
let Ok((_, outer)) = parse_der_sequence(der) else {
return false;
};
let Ok(fields) = outer.as_sequence() else {
return false;
};
let version = fields.first().and_then(|v| v.as_u32().ok());
let content_type = fields
.get(1)
.and_then(|info| info.as_sequence().ok())
.and_then(|info| info.first())
.and_then(|oid| oid.as_oid().ok())
.map(|oid| oid.to_id_string());
version == Some(3) && content_type.as_deref() == Some("1.2.840.113549.1.7.1")
}
const RSA: &str = "1.2.840.113549.1.1.1";
const EC: &str = "1.2.840.10045.2.1";
const ED25519: &str = "1.3.101.112";
const ED448: &str = "1.3.101.113";
/// What a public key is, and the bytes that make it that key.
fn describe(spki: &SubjectPublicKeyInfo) -> (String, Option<Vec<u8>>) {
match spki.parsed() {
Ok(PublicKey::RSA(rsa)) => (rsa_name(rsa.modulus), Some(trim(rsa.modulus).to_vec())),
Ok(PublicKey::EC(point)) => {
let curve = spki
.algorithm
.parameters
.as_ref()
.and_then(|p| p.as_oid().ok())
.map(|oid| curve_name(&oid.to_id_string()));
(ec_name(curve), Some(point.data().to_vec()))
}
_ => match spki.algorithm.algorithm.to_id_string().as_str() {
ED25519 => (
"Ed25519".into(),
Some(spki.subject_public_key.data.to_vec()),
),
ED448 => ("Ed448".into(), Some(spki.subject_public_key.data.to_vec())),
other => (other.to_string(), None),
},
}
}
/// `RSAPrivateKey ::= SEQUENCE { version, modulus, … }`.
fn rsa_modulus(der: &[u8]) -> Option<&[u8]> {
let (_, key) = parse_der_sequence(der).ok()?;
let fields = key.as_sequence().ok()?;
fields.get(1)?.as_slice().ok()
}
fn rsa_name(modulus: &[u8]) -> String {
let modulus = trim(modulus);
let bits = match modulus.first() {
Some(top) => modulus.len() * 8 - top.leading_zeros() as usize,
None => 0,
};
format!("RSA {bits}")
}
/// An unsigned integer as DER writes it may start with a zero byte.
fn trim(integer: &[u8]) -> &[u8] {
let zeros = integer.iter().take_while(|b| **b == 0).count();
&integer[zeros..]
}
fn curve_name(oid: &str) -> String {
match oid {
"1.2.840.10045.3.1.7" => "P-256".into(),
"1.3.132.0.34" => "P-384".into(),
"1.3.132.0.35" => "P-521".into(),
"1.3.132.0.10" => "secp256k1".into(),
other => other.into(),
}
}
fn ec_name(curve: Option<String>) -> String {
match curve {
Some(curve) => format!("EC {curve}"),
None => "EC".into(),
}
}
fn name(general: &GeneralName) -> String {
match general {
GeneralName::DNSName(dns) => dns.to_string(),
GeneralName::RFC822Name(mail) => mail.to_string(),
GeneralName::URI(uri) => uri.to_string(),
GeneralName::IPAddress(ip) => {
if let Ok(v4) = <[u8; 4]>::try_from(*ip) {
std::net::Ipv4Addr::from(v4).to_string()
} else if let Ok(v6) = <[u8; 16]>::try_from(*ip) {
std::net::Ipv6Addr::from(v6).to_string()
} else {
hex::encode(ip)
}
}
other => other.to_string(),
}
}
/// `AB:CD:…`, as every other tool prints one.
fn fingerprint(digest: &[u8]) -> String {
digest
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(":")
}
#[cfg(test)]
mod tests {
use super::*;
// Throwaway material, made once with OpenSSL 3 and nothing else signed by it:
// openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \
// -days 3650 -subj "/CN=narl test/O=narl" -addext "subjectAltName=DNS:example.test"
// openssl x509 -in cert.pem -outform DER -out cert.der
// openssl req -new -key key.pem -out req.csr -subj "/CN=narl request"
// openssl pkcs8 -topk8 -in key.pem -out key.enc.pem -v2 aes-256-cbc -passout pass:test
// openssl pkcs12 -export -inkey key.pem -in cert.pem -out modern.p12 -passout pass:test
// openssl pkcs12 -export -legacy -inkey key.pem -in cert.pem -out legacy.p12 -passout pass:test
// openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out ec.pem
// cat cert.pem key.pem > bundle.pem
const CERT_PEM: &[u8] = include_bytes!("testdata/cert.pem");
const CERT_DER: &[u8] = include_bytes!("testdata/cert.der");
const BUNDLE: &[u8] = include_bytes!("testdata/bundle.pem");
const REQUEST: &[u8] = include_bytes!("testdata/req.csr");
const ENCRYPTED_KEY: &[u8] = include_bytes!("testdata/key.enc.pem");
const EC_KEY: &[u8] = include_bytes!("testdata/ec.pem");
const MODERN: &[u8] = include_bytes!("testdata/modern.p12");
const LEGACY: &[u8] = include_bytes!("testdata/legacy.p12");
const SHA256: &str = "EA:C1:0E:4D:3C:41:C2:73:84:5E:7F:E0:7A:C2:39:9C:\
0E:6A:D1:41:7D:BE:F6:DC:36:C7:56:66:E4:88:BD:75";
fn certificate(item: &Item) -> (&str, &str, &[String], &str) {
match item {
Item::Certificate {
subject,
key,
names,
sha256,
..
} => (subject, key, names, sha256),
other => panic!("not a certificate: {other:?}"),
}
}
#[test]
fn reads_a_certificate_as_pem_and_as_der() {
for bytes in [CERT_PEM, CERT_DER] {
let found = inspect_bytes(bytes, None).unwrap();
assert_eq!(found.items.len(), 1);
let (subject, key, names, sha256) = certificate(&found.items[0]);
assert!(subject.contains("CN=narl test"), "{subject}");
assert_eq!(key, "RSA 2048");
assert_eq!(names, ["example.test"]);
assert_eq!(sha256, SHA256);
}
}
#[test]
fn pairs_a_key_with_its_certificate() {
let found = inspect_bytes(BUNDLE, None).unwrap();
assert_eq!(found.items.len(), 2);
assert_eq!(
found.items[1],
Item::PrivateKey {
key: Some("RSA 2048".into()),
encrypted: false,
matches: Some(0),
label: None,
}
);
}
#[test]
fn reads_a_request() {
let found = inspect_bytes(REQUEST, None).unwrap();
assert!(matches!(
&found.items[..],
[Item::Request { subject, key, .. }] if subject.contains("narl request") && key == "RSA 2048"
));
}
#[test]
fn reads_an_ec_key() {
let found = inspect_bytes(EC_KEY, None).unwrap();
assert!(matches!(
&found.items[..],
[Item::PrivateKey { key: Some(key), encrypted: false, .. }] if key == "EC P-256"
));
}
#[test]
fn an_encrypted_key_asks_for_its_password() {
let found = inspect_bytes(ENCRYPTED_KEY, None).unwrap();
assert!(found.needs_password);
assert!(matches!(
&found.items[..],
[Item::PrivateKey {
key: None,
encrypted: true,
..
}]
));
assert_eq!(
inspect_bytes(ENCRYPTED_KEY, Some("nope")).unwrap_err(),
Failure::WrongPassword
);
let found = inspect_bytes(ENCRYPTED_KEY, Some("test")).unwrap();
assert!(!found.needs_password);
assert!(matches!(
&found.items[..],
[Item::PrivateKey { key: Some(key), encrypted: true, .. }] if key == "RSA 2048"
));
}
#[test]
fn opens_modern_and_legacy_keystores() {
for bytes in [MODERN, LEGACY] {
let locked = inspect_bytes(bytes, None).unwrap();
assert!(locked.needs_password);
assert!(locked.items.is_empty());
assert_eq!(
inspect_bytes(bytes, Some("nope")).unwrap_err(),
Failure::WrongPassword
);
let found = inspect_bytes(bytes, Some("test")).unwrap();
assert_eq!(found.items.len(), 2, "{:?}", found.items);
assert_eq!(certificate(&found.items[0]).3, SHA256);
assert!(matches!(
&found.items[1],
Item::PrivateKey { key: Some(key), encrypted: true, matches: Some(0), .. } if key == "RSA 2048"
));
}
}
#[test]
fn never_answers_with_key_material() {
let found = inspect_bytes(MODERN, Some("test")).unwrap();
let json = serde_json::to_string(&found).unwrap();
// The longest field anywhere is a fingerprint; a key would be kilobytes.
assert!(json.len() < 2048, "{json}");
}
#[test]
fn refuses_what_it_cannot_read() {
assert_eq!(
inspect_bytes(b"hello, world", None).unwrap_err(),
Failure::Unreadable
);
assert_eq!(
inspect_bytes(
b"-----BEGIN NOTHING-----\naGk=\n-----END NOTHING-----\n",
None
)
.unwrap_err(),
Failure::Unreadable
);
}
}
+11 -4
View File
@@ -500,6 +500,15 @@ pub async fn stream_media(
"attachment" "attachment"
}; };
// A PDF is shown in a frame of the file pane, by the browser's own viewer,
// and that viewer will not start inside a sandbox. It runs no script of
// the origin's either way, and nothing but the app may frame it.
let (policy, frames) = if mime == "application/pdf" {
("default-src 'none'; frame-ancestors 'self'", "SAMEORIGIN")
} else {
("sandbox; default-src 'none'", "DENY")
};
let key = match (encrypted, private) { let key = match (encrypted, private) {
(false, _) => None, (false, _) => None,
(true, false) => return Err(AppError::NotFound), (true, false) => return Err(AppError::NotFound),
@@ -559,10 +568,8 @@ pub async fn stream_media(
// Belt and braces: never let the browser sniff past the type above, // Belt and braces: never let the browser sniff past the type above,
// and strip the ambient authority of the origin from the response. // and strip the ambient authority of the origin from the response.
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
( (header::CONTENT_SECURITY_POLICY, policy.to_string()),
header::CONTENT_SECURITY_POLICY, (header::X_FRAME_OPTIONS, frames.to_string()),
"sandbox; default-src 'none'".to_string(),
),
], ],
body, body,
) )
+2
View File
@@ -2,6 +2,7 @@ pub mod backup;
pub mod boards; pub mod boards;
pub mod export; pub mod export;
pub mod import; pub mod import;
pub mod inspect;
pub mod manifest; pub mod manifest;
pub mod media; pub mod media;
pub mod nodes; pub mod nodes;
@@ -68,6 +69,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/nodes/{id}/files", post(media::upload_into).layer(uploads)) .route("/nodes/{id}/files", post(media::upload_into).layer(uploads))
.route("/nodes/{id}/encrypt", post(seal::encrypt)) .route("/nodes/{id}/encrypt", post(seal::encrypt))
.route("/nodes/{id}/decrypt", post(seal::decrypt)) .route("/nodes/{id}/decrypt", post(seal::decrypt))
.route("/nodes/{id}/inspect", post(inspect::inspect))
.route( .route(
"/vault", "/vault",
get(vault::status) get(vault::status)
+48
View File
@@ -0,0 +1,48 @@
-----BEGIN CERTIFICATE-----
MIIDQDCCAiigAwIBAgIUNstWxYdASmgHY27nUxFIRmYp56QwDQYJKoZIhvcNAQEL
BQAwIzESMBAGA1UEAwwJbmFybCB0ZXN0MQ0wCwYDVQQKDARuYXJsMB4XDTI2MTAy
MDA3NDEyNVoXDTM2MTAxNzA3NDEyNVowIzESMBAGA1UEAwwJbmFybCB0ZXN0MQ0w
CwYDVQQKDARuYXJsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApdmT
uYCy0Gn6x0N4wbZ00fKQpce1cxEHyZv1RWsSNwXptGjX3dh6g7bVQAYnuG3mJ+ML
ygW9Og68LPM4L3Irim303MqQ7TqYnXJcO1TxnAS6gNPHfnxSEvJ81IcBCg1TCK1q
FE4Zr+55cYMpmn+WUWRo/bUoEzKvo6XHxcWpWx8m5nwG/qDUqtEr7T4zW2gs8n9M
wnumXlokHIqImJXy/JykpK7c27vPpyRnUpdqva8ZTYzDKl6O83s53ihrSeog75g6
pgIGAo05FF4R2As6RkD3QaffnO3qZ0nO4L0NtEbSAs/bm6VBZ3JvJxd7vXaL9kbT
sG00yqVGQ8QZZmx8fwIDAQABo2wwajAdBgNVHQ4EFgQUGV5Pi/qBOPhZd8H1YdNk
+nQwHecwHwYDVR0jBBgwFoAUGV5Pi/qBOPhZd8H1YdNk+nQwHecwDwYDVR0TAQH/
BAUwAwEB/zAXBgNVHREEEDAOggxleGFtcGxlLnRlc3QwDQYJKoZIhvcNAQELBQAD
ggEBAHWFOl7tK5p4hV1wq4NBmttb0XUjbbR3SJapR7FXMzqjlisQ2L1udqe32nCw
aTZsm4e+R/25RWSPffb1boMMCy3t0QIEuUL7TPKzjNsdXTGvj9RUJ/isXHS7/Yms
FlT8xQWYWcoDjkmp2CS9IX/N0pkyd0oIrzN+KvkSS9596R6AKT+x7VYa0vPyp3Nt
o4PZK7189fyfIrktepyjnB0nULSbzPiHtogs85NBohOoXlZOdFweJKTFcxutpLd4
J0+zjWCWZnrCUTKBTCtCm2GjG1X2fuJ29vv6IqCB8NKCXZcdkpyilnhx8PjL4koh
0BoItRF2mYQgmqh45xTw6aa+PZg=
-----END CERTIFICATE-----
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCl2ZO5gLLQafrH
Q3jBtnTR8pClx7VzEQfJm/VFaxI3Bem0aNfd2HqDttVABie4beYn4wvKBb06Drws
8zgvciuKbfTcypDtOpidclw7VPGcBLqA08d+fFIS8nzUhwEKDVMIrWoUThmv7nlx
gymaf5ZRZGj9tSgTMq+jpcfFxalbHybmfAb+oNSq0SvtPjNbaCzyf0zCe6ZeWiQc
ioiYlfL8nKSkrtzbu8+nJGdSl2q9rxlNjMMqXo7zezneKGtJ6iDvmDqmAgYCjTkU
XhHYCzpGQPdBp9+c7epnSc7gvQ20RtICz9ubpUFncm8nF3u9dov2RtOwbTTKpUZD
xBlmbHx/AgMBAAECggEAENVwxRdnDvhy197lVuQqH1U7kVgf9rn3zGLkFlOz2Znk
CsfJgV8DsHLSWllFltaw0cOd4reKaWYte3KEdWxlNvvKKs70vt9/thtxeSQ6Edf5
BjJ4wUHlIr8yJHA/jsGjd/ICHmV+/fh6oybpBiGFJGXIsle85ZXzdezRxiU192Wc
O7dAdgJv6EjG75kZX84/Nt/Rs2FS3KPEzOtLKR5T+FMxCIrYgIbPDG4VtTNi25S7
spfE8r2swzfaGNXJna/XMzEUmbmA5jcdhwggm1GnbMt6Pa3eMZ/VFC+e+nPM1Ldn
pphe+1xJ/gLN42j9Dr/3m/yH8M8ilaDC3aQYTMG9QQKBgQDRJfeS2gmpuOGzxkc8
R1bDGQICZ4rMmTqp1WzXX56Zg/bLAkSHqXbTZVgVk9nrYWYfFgGILroWHvQGz8uY
hcaqGQLUxrpIpeO/epXyS3T5VoczZGFqIiowRmOya7Nw23oMBip54AnnU3DhCE+n
+AyY6HqzVWF3n0r+WwdESdlpBwKBgQDLAJNhAYjufxIis6nN8SN2RoPjP0T4BTbS
Jno00wBj1kZbG2pyUJ81tJe+dAmx5GHxd+kmkQQWRmtJMmOAlUj9uclIhBFMYb3O
B8jAvoethdurMI1GjDxPmiz0gdyJRttiKF0ftBeHP2LZKDbMi80mKc8N1NjO/zKL
tCFvBHtKyQKBgBP9hQRMQtAuE6STNwvT5b8QDNkd/G5B1xGUjKqpQQd7TYNp3BL7
Z5gc/6ZQU2vkC1a9S7gwfyjOHJTqF5OkdbCcY2Di/bsj3/jVRq+59pw+YP3Qgl8a
lN24XJ8ZHDWVRL0Rec3cVBYAK7HWkYaEdu/GiLPLXV2JpClbmf408MrRAoGBALLC
CQWNAnVxtBiWuNo+k4mdQpojpqn4fXPEqixF+bvwfffQRoXvdKMoKc9uAvOBNrYZ
b1ekr0KmM+c61d/ulV3H8fVHRqxKDYeV1zPTE0q76tnuYRlUd+9oxllw2TCRErJ2
9LnsLiPMr1CmVsr7+GS4wDlfyqNMmcfnkOFfwunxAoGAUo1EvhJOkVDIqDGqViGo
dbUs584FnF4Ip1wcUGCBKGCMfGrIBtO8tCBW7sbOkI9tYI9usGlQx/1nQDr9Da5p
jgDJxrd/1pCyraEvcDLE6RQLPbZTFDYMPM5VvlK/gf6HABj1r/QoUcAjs50Llk0I
6TlAKjNTMX/i8lgdNCjFrx0=
-----END PRIVATE KEY-----
BIN
View File
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDQDCCAiigAwIBAgIUNstWxYdASmgHY27nUxFIRmYp56QwDQYJKoZIhvcNAQEL
BQAwIzESMBAGA1UEAwwJbmFybCB0ZXN0MQ0wCwYDVQQKDARuYXJsMB4XDTI2MTAy
MDA3NDEyNVoXDTM2MTAxNzA3NDEyNVowIzESMBAGA1UEAwwJbmFybCB0ZXN0MQ0w
CwYDVQQKDARuYXJsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApdmT
uYCy0Gn6x0N4wbZ00fKQpce1cxEHyZv1RWsSNwXptGjX3dh6g7bVQAYnuG3mJ+ML
ygW9Og68LPM4L3Irim303MqQ7TqYnXJcO1TxnAS6gNPHfnxSEvJ81IcBCg1TCK1q
FE4Zr+55cYMpmn+WUWRo/bUoEzKvo6XHxcWpWx8m5nwG/qDUqtEr7T4zW2gs8n9M
wnumXlokHIqImJXy/JykpK7c27vPpyRnUpdqva8ZTYzDKl6O83s53ihrSeog75g6
pgIGAo05FF4R2As6RkD3QaffnO3qZ0nO4L0NtEbSAs/bm6VBZ3JvJxd7vXaL9kbT
sG00yqVGQ8QZZmx8fwIDAQABo2wwajAdBgNVHQ4EFgQUGV5Pi/qBOPhZd8H1YdNk
+nQwHecwHwYDVR0jBBgwFoAUGV5Pi/qBOPhZd8H1YdNk+nQwHecwDwYDVR0TAQH/
BAUwAwEB/zAXBgNVHREEEDAOggxleGFtcGxlLnRlc3QwDQYJKoZIhvcNAQELBQAD
ggEBAHWFOl7tK5p4hV1wq4NBmttb0XUjbbR3SJapR7FXMzqjlisQ2L1udqe32nCw
aTZsm4e+R/25RWSPffb1boMMCy3t0QIEuUL7TPKzjNsdXTGvj9RUJ/isXHS7/Yms
FlT8xQWYWcoDjkmp2CS9IX/N0pkyd0oIrzN+KvkSS9596R6AKT+x7VYa0vPyp3Nt
o4PZK7189fyfIrktepyjnB0nULSbzPiHtogs85NBohOoXlZOdFweJKTFcxutpLd4
J0+zjWCWZnrCUTKBTCtCm2GjG1X2fuJ29vv6IqCB8NKCXZcdkpyilnhx8PjL4koh
0BoItRF2mYQgmqh45xTw6aa+PZg=
-----END CERTIFICATE-----
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg6QYjjpOkpU68jWi0
wJshV+16QQ/6nNExwkuP6zYz7QehRANCAASEsy/3jV1m2Qq+5iHAMZdOj7Nj3J0P
HbiYE9jtt6ZCE53DLAbFYGsYKXNRYugJ5bhl9heh7BNrm6NmiSKgs/P+
-----END PRIVATE KEY-----
+30
View File
@@ -0,0 +1,30 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQL+9PzyyhW8kQwpiy
IrK/igICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEENqTMbj5l63Lxc72
QyJiWHYEggTQqiYQPexKaZ6LjXd8T0z2O2PDJpF4+f9GfYbWLvo7vuwtNxgzlga9
u8qVnqeNb8D9UvevaAMI28TU0agxFZ+QD3jgE9GZaEXw09Vcuis1DkUYtHBDs1V+
+KgHIbhy+8WF99oRfDXYbWfXLLe5tlmv+LWu4koge9QACjRhuLKzRH2IErhlhMfA
DC9pZYaAK9o+noI1oNLMFVL1UinN7jEkBtFVsLfm5IKh68btBFfMHVV97LZRApFe
ve7/UOovx8vsDrAu4LtVcvLeAX0B2JmPiqMNUHMbKkiTyPMDMOQWn1YOzDaJ6NnR
YVp73PIq/SL25c3BOfspNmh/piWl1yIED8ljd0HA2XROC0YiMjY0rixYDckg1xyo
foMqXU7/7sLgMfUs8mc7gD4C+SVgdfwmifwgdsauzMUH32ikiheMxMjuru5Y/YVK
LabTeOk0fTefUwHbc0UqugHUGa3flMHKDaw89Y8adfiw5MhFJujR0Q4WD6daHyP5
kxzzkWnmfF5/B8jDRCN0lltQ91L1O0AKmAfTTbeWlbDVxrngma3x71EwKTY8dpNO
NseyhGHl0fgd8mMCaSEFi7QQ9mKmxaWM6uk8JcE2x1XNSULF5UanXswFNwKaXz98
bt53EOPcpVp60hY+tOvVxftEMOZyVcBl6RxbcvIp4fjqwXpHhbs3UXMAcWfTMdn/
YxY5Z670KwTHMieJf6i4DeVGoV4hJFjebnS9uMP8YDw8Va/aGEUyLfbqC6FeRbis
tsaemX4e3w2Q3F9YVrYiTq8EbCur1Gass88JGp6hPjNZg/O5gG69LLxXJOPLOid0
9/O1kpOC2aEra2GPWOQCsv1tLSf+p9ArdmflFLqlcTICd3dMMnfeNM6MKCvAksZD
eAJVsv3YoDEQZnF/1q86lN8GulOPFWjz0UCOE/1cANqauJ1zzJhxuHFRThcfg1H7
ojIPd0n3Fu4vnoJ/w7aDdYtaJvBHhgEa/u+mK95gALgznrUtTDCk4wwMu5whsVJ5
PpVs3VEyg3Npwq+kTWPEX+CBQYlxJCIX5Bm42I9hBo7l0vN0Ai+G8Rus2SQVP3kt
4yx4FI1i+PEwo9LX7SRdbjqPS1SUkC6WehQ3diCCQ/P+glgtEAhNqYflaV8FsiSk
YvNtfQoeMFMkDLH1NZUh3/VulvOoIO8Lm6W+k8o+UmctHppNYOa8MfoyyNDcnPJ5
etbRdeACrTk4o6RL4dN8PQWcsKvZuVyBDe4WKRxSVx99ADMtk2jGnsvP+f31h06t
39Qc2DY4nceZJybHGljQHTiolzxtsqIeXy+T69rBush4dWGfXDqWuxlU9+gf/aFH
RKMTHlokn+BYwqZMDLK0aV++MpJiYfO1URmD8Os7COZBxLxrOZVV9GOQMCXYEhOr
bchvfTegrZkmLvP2iNvfHN0wvI9XQM8HEBLY9YDi27HNBvtMbjX1KKfyhIVDbjmU
+TuZYIwUklTMMUrui/f2gmUDw4vtreJmEmYhhMgE9NSUYew9tXtTCQl/1fvmotIC
4lZ+p/mo3AugnquU8tvV5LghUSUW9Rjg/0qWWHmXQET5cahDdKbaLJYSDD2leYUw
5+fiXZetvyGo73Q8yKyh3yaMSJnV5B4JscSCWi3S87OQGb9gCE6wNPk=
-----END ENCRYPTED PRIVATE KEY-----
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICXDCCAUQCAQAwFzEVMBMGA1UEAwwMbmFybCByZXF1ZXN0MIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEApdmTuYCy0Gn6x0N4wbZ00fKQpce1cxEHyZv1
RWsSNwXptGjX3dh6g7bVQAYnuG3mJ+MLygW9Og68LPM4L3Irim303MqQ7TqYnXJc
O1TxnAS6gNPHfnxSEvJ81IcBCg1TCK1qFE4Zr+55cYMpmn+WUWRo/bUoEzKvo6XH
xcWpWx8m5nwG/qDUqtEr7T4zW2gs8n9MwnumXlokHIqImJXy/JykpK7c27vPpyRn
Updqva8ZTYzDKl6O83s53ihrSeog75g6pgIGAo05FF4R2As6RkD3QaffnO3qZ0nO
4L0NtEbSAs/bm6VBZ3JvJxd7vXaL9kbTsG00yqVGQ8QZZmx8fwIDAQABoAAwDQYJ
KoZIhvcNAQELBQADggEBAJkqFdTGJdCXRlfk8RcxxOygNuluqSG/dnYa6Xz87XxQ
6CbHzWiXJ97AAlh49N6nFH/qdSF2tLgtCQbFQOpVBk8wf/rukRvS8t4g633nQGpc
Y0il0mQf/pN1fEfZW2Y/8GjTWCK4FQHQK0jQPAc7jA1OiFn4tP77dNyJRUK951gy
MgAy+FHZpNO2PrVrjA1fNGh3X6ePzX6/cARiA7SiYVWXDB85WyBjFENJoTsZ5Rlf
QAjDf3iCnFI1yb1c0HqGdf1vZ4P9u41BB728Gdh3bV6U7LKuX3p8yaSNxZwu7Xj6
OR/o4vgje16xLOlzUnXTBebW9JOK08yzi+Jf2IRKQD4=
-----END CERTIFICATE REQUEST-----
+159
View File
@@ -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
View File
@@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import CertView from './CertView.svelte'
import { hooks, runCommand, type ScrollAmount } from './commands' import { hooks, runCommand, type ScrollAmount } from './commands'
import Icon from './Icon.svelte' import Icon from './Icon.svelte'
import { renderMarkdown } from './markdown'
import { looksLikeText, parseTable, previewKind, separatorOf } from './preview'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
import { formatBytes, formatStamp, relative, scrollPage } from './util' import { formatBytes, formatStamp, relative, scrollPage } from './util'
@@ -8,27 +11,47 @@
const file = $derived(node.file!) const file = $derived(node.file!)
/* A sealed file cannot be fetched while the vault is shut. */ /* A sealed file cannot be fetched while the vault is shut. */
const locked = $derived(file.encrypted && !workspace.vault?.unlocked) 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. */ /* What the browser can show by itself; everything else is a download. */
const viewable = $derived(file.mime !== 'application/octet-stream') const viewable = $derived(file.mime !== 'application/octet-stream')
/* /*
* Plain text is shown inline, but only its beginning: a log file can run to * Text is shown inline, but only its beginning: a log file can run to
* gigabytes, and the download has the rest. * 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 TEXT_PREVIEW = 256 * 1024
const SNIFF = 8 * 1024
let text = $state<string | null>(null) let text = $state<string | null>(null)
/* Markdown is read rendered, unless asked for its source. */
let source = $state(false)
$effect(() => { $effect(() => {
text = null 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 const url = file.url
void head(url, TEXT_PREVIEW).then((body) => { const sniff = kind === 'none'
if (body !== null && url === file.url) text = body 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. */ const lines = $derived(text === null ? [] : text.replace(/\n$/, '').split('\n'))
async function head(url: string, limit: number): Promise<string | null> {
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) const response = await fetch(url)
if (!response.ok || !response.body) return null if (!response.ok || !response.body) return null
const reader = response.body.getReader() const reader = response.body.getReader()
@@ -42,7 +65,7 @@
size += take.length size += take.length
} }
void reader.cancel() void reader.cancel()
return new TextDecoder().decode(bytes.subarray(0, size)) return bytes.subarray(0, size)
} }
let reader = $state<HTMLElement>() let reader = $state<HTMLElement>()
@@ -89,6 +112,9 @@
{#if locked} {#if locked}
<button onclick={() => runCommand('unlock')}>unlock</button> <button onclick={() => runCommand('unlock')}>unlock</button>
{:else} {:else}
{#if kind === 'markdown' && text !== null}
<button onclick={() => (source = !source)}>{source ? 'rendered' : 'source'}</button>
{/if}
{#if viewable} {#if viewable}
<a class="button" href={file.url} target="_blank" rel="noopener">open</a> <a class="button" href={file.url} target="_blank" rel="noopener">open</a>
{/if} {/if}
@@ -118,8 +144,38 @@
<video src={file.url} controls playsinline preload="metadata"></video> <video src={file.url} controls playsinline preload="metadata"></video>
{:else if kind === 'audio'} {:else if kind === 'audio'}
<audio src={file.url} controls preload="metadata"></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} {: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} {#if file.size > TEXT_PREVIEW}
<p class="muted">the first {formatBytes(TEXT_PREVIEW)} of {formatBytes(file.size)} — download it for the rest.</p> <p class="muted">the first {formatBytes(TEXT_PREVIEW)} of {formatBytes(file.size)} — download it for the rest.</p>
{/if} {/if}
@@ -215,6 +271,36 @@
overflow-wrap: anywhere; 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); } .light { color: var(--light); }
.sealed { display: flex; align-items: center; gap: 8px; color: var(--subtle); } .sealed { display: flex; align-items: center; gap: 8px; color: var(--subtle); }
+2 -1
View File
@@ -141,7 +141,8 @@
<footer> <footer>
inside the editor the full vim keymap is live — hjkl, w/b, dd, ciw, visual mode, macros, and 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 <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 — deletes are copied to the system clipboard —
<span class="key">:set noclipboard</span> keeps them in vim's registers. <span class="key">:set noclipboard</span> keeps them in vim's registers.
</footer> </footer>
+40
View File
@@ -141,6 +141,42 @@ export interface MediaFile {
file_node: number | null 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. */ /** The vault, as `/api/vault` reports it. */
export interface VaultStatus { export interface VaultStatus {
configured: boolean configured: boolean
@@ -403,6 +439,10 @@ export const api = {
method: 'POST', 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'), backupStatus: () => request<BackupStatus>('/backup'),
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }), backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
+84
View File
@@ -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' : ',')