Content-sniffed file types, sandboxed HTML/SVG serving, open in new tab

File type detection moves from four extension lists to the file's actual
bytes. `api-types` gains `FileKind`; the server sniffs the first 256 bytes
per listing entry with `infer` (zero-dependency build) plus a NUL/UTF-8
heuristic for the formats that carry no magic number, and reports the kind
on the wire. The client's icon and preview choices read that instead of
guessing from the name, so a PNG named .txt gets an image preview and
extensionless files like Makefile open in the editor.

Deleted the extension lists this replaces: IMAGE/VIDEO/AUDIO_EXTS in
preview.rs, the match in icon_for, cm::is_text, and TEXT_EXTS/EXTLESS_TEXT
in wrapper.js. wrapper.js keeps EXT_TO_LANG only — a grammar genuinely is
an extension question, a .h is C or C++.

Files the browser parses as documents (HTML/SVG/XML) are now served under
a sandboxed CSP so they render as real pages without being able to act as
the app: `allow-scripts` without `allow-same-origin` forces an opaque
origin, so their JS has no session, no localStorage and no CORS read
access to the API. The app's CSP layer becomes `if_not_present` so the
handler's policy survives. Non-scriptable types keep the app policy, which
leaves the existing preview modal (including the PDF iframe) unchanged.

New context-menu item "Open in new tab" serves the file itself via
?action=preview, opened with `noopener`.

Also extracts `open_callback`, which was duplicated verbatim between
grid_view and list_view.

The wire Content-Type still comes from `mime_guess`: `infer` cannot detect
HTML or SVG at all, so sniffing it would break rendering. Extension drives
the served type and the sandbox decision together, from one value, so the
two can never disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commita0abecdb02b7d3987c26f079172e15113eb11084
Parent066c622
13 files changed, 515 insertions(+), 150 deletions(-)
▾MCargo.lock
@@ -1248,6 +1248,12 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "infer"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847"
[[package]]
name = "inout"
version = "0.2.2"
@@ -2225,6 +2231,7 @@ dependencies = [
"flate2",
"futures-util",
"http-body-util",
"infer",
"mime_guess",
"multer",
"rand",
▾Mapi-types/src/lib.rs
@@ -132,6 +132,28 @@ pub struct CreateShare {
// Responses (server → client)
// ---------------------------------------------------------------------------
/// What a listing entry actually is, decided by the server from the file's
/// leading bytes (magic numbers via `infer`, plus a text/binary heuristic) —
/// not from its name. Drives the icon and the preview the client offers.
///
/// Deliberately coarse: this answers "which viewer opens this", not "what
/// exact format is it". Syntax highlighting still keys off the extension,
/// because `.h` is C or C++ and no amount of sniffing decides that.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FileKind {
Dir,
Image,
Video,
Audio,
Pdf,
Archive,
/// Anything that decodes as text: source code, markup, config, plain text.
Text,
/// Recognized-but-not-viewable, or undecodable bytes.
Binary,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Entry {
pub name: String,
@@ -139,6 +161,8 @@ pub struct Entry {
pub size: u64,
/// RFC 3339 UTC modification time.
pub mtime: String,
/// Content-sniffed kind (see [`FileKind`]).
pub kind: FileKind,
}
#[derive(Serialize, Deserialize)]
▾Mserver/Cargo.toml
@@ -14,6 +14,9 @@ argon2 = "0.5"
axum = "0.8"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
# `default-features = false` drops the `cfb` dependency (compound-document
# detection we do not need) and makes this a zero-dependency crate.
infer = { version = "0.16", default-features = false, features = ["alloc"] }
mime_guess = "2"
multer = { version = "3", features = ["tokio-io"] }
rand = "0.8"
▾Mserver/src/api/files.rs
@@ -328,11 +328,17 @@ async fn file_response(
format!("attachment; filename=\"{}\"", disp_name(name))
};
let body = stream_file(full.to_path_buf());
Response::builder()
let mut res = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_DISPOSITION, disp)
.header(header::CONTENT_LENGTH, size)
.header(header::CONTENT_LENGTH, size);
// A file the browser would parse as a document (HTML/SVG/XML) is served
// under the sandboxed policy, so it can render as a page without being
// able to act as the app. Derived from the same `mime` we declare.
if crate::api::is_scriptable_mime(&mime) {
res = res.header("content-security-policy", crate::api::FILE_CSP);
}
res.header(header::CONTENT_TYPE, mime)
.body(body)
.map_err(|e| {
ApiError::new(
▾Mserver/src/api/mod.rs
@@ -19,6 +19,45 @@ use crate::error::AppState;
/// * Everything else locked to the same origin; frames/plugins banned.
const CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';";
/// Content-Security-Policy for served *user files* that the browser would
/// treat as a scripting document (HTML, SVG, XML). Lets such a file render as
/// a real page — the "share an HTML page" flow — without letting it act as the
/// app.
///
/// The security hinges on one omission: `allow-scripts` **without**
/// `allow-same-origin`. That forces the document into a unique opaque origin,
/// so its JavaScript cannot read the session cookie's origin, cannot touch
/// `localStorage`, and cannot call `/api` as the viewer (the server sends no
/// CORS headers, so every cross-origin read fails). Never add
/// `allow-same-origin` here.
///
/// Also deliberately absent:
/// * `allow-top-navigation` — a shared page cannot silently redirect the
/// viewer elsewhere. `-by-user-activation` still lets links work on click.
/// * `allow-popups-to-escape-sandbox` — a popup would drop the sandbox.
///
/// `connect-src *` is deliberate: a shared page may call third-party APIs.
/// The trade-off is that it can also beacon (report that the link was opened,
/// and anything the page itself contains). It cannot exfiltrate anything of
/// the viewer's — the opaque origin means it has no session and no CORS read
/// access to this server.
pub(crate) const FILE_CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src *; object-src 'none'; frame-ancestors 'none'; sandbox allow-scripts allow-forms allow-modals allow-downloads allow-popups allow-top-navigation-by-user-activation;";
/// True for MIME types the browser parses as a scripting document. These are
/// the responses that need [`FILE_CSP`]; everything else keeps the app policy.
///
/// This reads the *declared* type — the same value that becomes the
/// `Content-Type` header — on purpose. If the sandbox decision and the render
/// decision ever read different inputs, a file can be rendered as a scripting
/// document without being sandboxed.
pub(crate) fn is_scriptable_mime(mime: &str) -> bool {
let base = mime.split(';').next().unwrap_or("").trim();
matches!(
base,
"text/html" | "application/xhtml+xml" | "image/svg+xml" | "text/xml" | "application/xml"
) || base.ends_with("+xml")
}
mod admin;
mod auth;
mod common;
@@ -59,7 +98,9 @@ pub fn router(state: Arc<AppState>) -> Router {
.fallback(spa::fallback)
.with_state(state)
// Hard security headers on every response (API and static alike).
.layer(SetResponseHeaderLayer::overriding(
// CSP is `if_not_present` so that file responses can substitute the
// sandboxed [`FILE_CSP`]; everything else gets the app policy.
.layer(SetResponseHeaderLayer::if_not_present(
"content-security-policy".parse().unwrap(),
HeaderValue::from_static(CSP),
))
▾Mserver/src/fs.rs
@@ -2,12 +2,13 @@
//! `<server-root>/<user-root>/<requested-path>`, canonicalizes it and verifies
//! the result is still inside the user's root (blocks `..` and symlink escapes).
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
use chrono::DateTime;
use api_types::Entry;
use api_types::{Entry, FileKind};
use crate::error::ApiError;
@@ -104,8 +105,9 @@ pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> {
let mut entries = Vec::new();
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
let path = e.path();
// Follows symlinks; a broken link shows up as an empty file.
let meta = std::fs::metadata(e.path());
let meta = std::fs::metadata(&path);
let (is_dir, size, mtime) = match meta {
Ok(m) => (m.is_dir(), m.len(), mtime_str(&m)),
Err(_) => (false, 0, "1970-01-01T00:00:00Z".to_string()),
@@ -115,6 +117,7 @@ pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> {
is_dir,
size,
mtime,
kind: detect_kind(&path, is_dir),
});
}
@@ -128,6 +131,96 @@ pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> {
Ok(entries)
}
// ---------------------------------------------------------------------------
// Content sniffing
// ---------------------------------------------------------------------------
/// How many leading bytes we read to classify a file. Every magic number
/// `infer` knows lives in the first few dozen bytes; 256 also gives the
/// text/binary heuristic enough to work with. Measured: ~4.6 µs per file,
/// against ~1.4 µs for the `metadata` call we already make.
const SNIFF_BYTES: usize = 256;
/// Classify a directory entry by reading its first [`SNIFF_BYTES`] bytes.
///
/// Blocking — only called from `list_dir` (itself under `spawn_blocking`).
/// An unreadable file is reported as [`FileKind::Binary`] rather than failing
/// the whole listing.
///
// ponytail: one open() per entry, serially. Measured ~4.6 µs/file warm
// (23 ms for 5000), against ~1.4 µs for the metadata call — fine on local
// disk. The ceiling is cold cache and network filesystems (NFS/SMB), where
// this becomes a round-trip per entry. If that shows up: sniff only entries
// whose size is non-zero and cache by (dev, ino, mtime), or fan the sniffs
// out across the blocking pool.
pub fn detect_kind(path: &Path, is_dir: bool) -> FileKind {
if is_dir {
return FileKind::Dir;
}
let mut head = [0u8; SNIFF_BYTES];
// A read error is *not* the same as an empty file: an empty file is text
// (it opens in the editor), an unreadable one gets no viewer offered.
match std::fs::File::open(path).and_then(|mut f| f.read(&mut head)) {
Ok(n) => kind_from_bytes(&head[..n], path),
Err(_) => FileKind::Binary,
}
}
/// The pure half of [`detect_kind`], so it can be unit-tested without a disk.
///
/// `path` is consulted only for the SVG case: SVG is XML text with no magic
/// number, but browsers render it as an image, so the extension is the only
/// thing that can tell us to offer an image preview.
fn kind_from_bytes(head: &[u8], path: &Path) -> FileKind {
if let Some(t) = infer::get(head) {
// PDF is filed under `Archive` by `infer`, so match the MIME first.
if t.mime_type() == "application/pdf" {
return FileKind::Pdf;
}
return match t.matcher_type() {
infer::MatcherType::Image => FileKind::Image,
infer::MatcherType::Video => FileKind::Video,
infer::MatcherType::Audio => FileKind::Audio,
infer::MatcherType::Archive => FileKind::Archive,
infer::MatcherType::Text => FileKind::Text,
// App / Book / Font / Doc / Custom: recognized, but nothing we
// can show in the browser.
_ => FileKind::Binary,
};
}
if !looks_like_text(head) {
return FileKind::Binary;
}
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
if ext == "svg" {
FileKind::Image
} else {
FileKind::Text
}
}
/// Text heuristic for the files `infer` has no signature for (plain text,
/// source code, most config formats): no NUL byte, and the head decodes as
/// UTF-8 once a truncated trailing character is discounted.
///
/// An empty file counts as text — it opens in the editor, which is what you
/// want for a file you just created.
fn looks_like_text(head: &[u8]) -> bool {
if head.contains(&0) {
return false;
}
match std::str::from_utf8(head) {
Ok(_) => true,
// A multi-byte character cut in half by the read boundary is fine;
// anything else is not text. `error_len() == None` means "unexpected
// end of input", i.e. truncation.
Err(e) => e.error_len().is_none() && e.valid_up_to() + 4 > head.len(),
}
}
fn mtime_str(m: &std::fs::Metadata) -> String {
let dt: Option<DateTime<chrono::Utc>> = m
.modified()
@@ -1005,4 +1098,110 @@ mod tests {
assert!(!is_within_or_eq(&docs, &root));
assert!(!is_within_or_eq(&docs, &root.join("src")));
}
// ---------- content sniffing ----------
fn kind(bytes: &[u8], name: &str) -> FileKind {
kind_from_bytes(bytes, Path::new(name))
}
#[test]
fn magic_numbers_classify_by_content() {
let png = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
// The name is a lie in every case: the bytes decide.
assert_eq!(kind(&png, "notes.txt"), FileKind::Image);
assert_eq!(kind(b"%PDF-1.7\n%aaa\n", "x.bin"), FileKind::Pdf);
assert_eq!(
kind(b"PK\x03\x04\x14\x00\x00\x00", "x.png"),
FileKind::Archive
);
assert_eq!(
kind(&[0x1F, 0x8B, 0x08, 0, 0, 0, 0, 0], "x"),
FileKind::Archive
);
assert_eq!(
kind(
&[
0, 0, 0, 0x20, b'f', b't', b'y', b'p', b'i', b's', b'o', b'm'
],
"x"
),
FileKind::Video
);
assert_eq!(
kind(b"ID3\x04\x00\x00\x00\x00\x00\x00", "x"),
FileKind::Audio
);
assert_eq!(kind(b"RIFF\x24\x00\x00\x00WAVEfmt ", "x"), FileKind::Audio);
assert_eq!(kind(b"<!doctype html><p>hi", "x"), FileKind::Text);
// Recognized but not viewable in a browser.
assert_eq!(
kind(&[0x00, 0x61, 0x73, 0x6d, 1, 0, 0, 0], "x.wasm"),
FileKind::Binary
);
}
#[test]
fn unsigned_files_fall_back_to_the_text_heuristic() {
// No magic number: plain text, source, config.
assert_eq!(kind(b"hello world\n", "notes"), FileKind::Text);
assert_eq!(kind(b"fn main() {}\n", "main.rs"), FileKind::Text);
assert_eq!(
kind("# über\nkey: wert\n".as_bytes(), "c.yaml"),
FileKind::Text
);
// Extensionless text files work, which the old extension list missed.
assert_eq!(kind(b"all:\n\tcargo build\n", "Makefile"), FileKind::Text);
// Empty file → editable.
assert_eq!(kind(b"", "new.txt"), FileKind::Text);
// A NUL byte means binary, whatever the name says. (ELF has no
// `infer` signature, so this is the path that catches it.)
assert_eq!(
kind(&[0x7F, b'E', b'L', b'F', 2, 1, 1, 0, 0], "run.txt"),
FileKind::Binary
);
assert_eq!(kind(&[0xC3, 0x28, 0xFF, 0xFE], "x.txt"), FileKind::Binary);
}
#[test]
fn svg_is_offered_as_an_image() {
// SVG is XML text with no magic number, but browsers draw it, so the
// extension is the only signal available.
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
assert_eq!(kind(svg, "logo.svg"), FileKind::Image);
assert_eq!(kind(svg, "logo.txt"), FileKind::Text);
}
#[test]
fn truncated_utf8_at_the_read_boundary_is_still_text() {
// 255 ASCII bytes plus the first byte of a 2-byte character: the read
// cut a character in half, which must not read as binary.
let mut b = vec![b'a'; SNIFF_BYTES - 1];
b.push(0xC3);
assert_eq!(kind(&b, "x.txt"), FileKind::Text);
}
#[test]
fn detect_kind_reads_from_disk() {
let t = T::new();
assert_eq!(detect_kind(&t.root.join("docs"), true), FileKind::Dir);
assert_eq!(detect_kind(&t.root.join("file.txt"), false), FileKind::Text);
// Unreadable / missing → Binary, never a failed listing.
assert_eq!(detect_kind(&t.root.join("nope"), false), FileKind::Binary);
}
#[test]
fn list_dir_reports_kinds() {
let t = T::new();
std::fs::write(
t.root.join("docs/pic.dat"),
[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
)
.unwrap();
let entries = list_dir(&t.root.join("docs")).unwrap();
let kind_of = |n: &str| entries.iter().find(|e| e.name == n).unwrap().kind;
assert_eq!(kind_of("inner"), FileKind::Dir);
assert_eq!(kind_of("a.txt"), FileKind::Text);
assert_eq!(kind_of("pic.dat"), FileKind::Image);
}
}
▾Mserver/tests/api_files.rs
@@ -714,3 +714,100 @@ async fn user_cannot_touch_foreign_root() {
StatusCode::FORBIDDEN
);
}
/// Listings report a content-sniffed `kind`, not an extension guess.
#[tokio::test]
async fn listing_reports_sniffed_kinds() {
let env = Env::new().await;
let admin = env.admin().await;
// A PNG named .txt and a text file named .png: the bytes must win.
std::fs::write(
env.file("lies.txt"),
[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
)
.unwrap();
std::fs::write(env.file("lies.png"), "just words\n").unwrap();
std::fs::write(env.file("report.html"), "<!doctype html><p>hi").unwrap();
std::fs::write(env.file("noext"), "plain text, no extension\n").unwrap();
let r = admin.get(&root_path("")).await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
let kind = |name: &str| -> String {
j["entries"]
.as_array()
.unwrap()
.iter()
.find(|e| e["name"] == name)
.unwrap_or_else(|| panic!("{name} missing from listing"))["kind"]
.as_str()
.unwrap()
.to_string()
};
assert_eq!(kind("lies.txt"), "image");
assert_eq!(kind("lies.png"), "text");
assert_eq!(kind("report.html"), "text");
assert_eq!(kind("noext"), "text");
assert_eq!(kind("blob.bin"), "binary");
assert_eq!(kind("docs"), "dir");
assert_eq!(kind("config.json"), "text");
}
/// A file the browser would parse as a document is served sandboxed, so it can
/// render as a page without being able to act as the app. Everything else
/// keeps the app policy.
#[tokio::test]
async fn scriptable_files_are_served_sandboxed() {
let env = Env::new().await;
let admin = env.admin().await;
std::fs::write(env.file("page.html"), "<!doctype html><p>hi").unwrap();
std::fs::write(
env.file("logo.svg"),
"<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
)
.unwrap();
for name in ["page.html", "logo.svg"] {
let r = admin
.get(&format!("{}?action=preview", root_path(name)))
.await;
assert_eq!(r.status, StatusCode::OK);
let csp = r.header("content-security-policy").unwrap();
assert!(csp.contains("sandbox "), "{name} not sandboxed: {csp}");
assert!(csp.contains("allow-scripts"), "{name}: {csp}");
// The whole security property: an opaque origin.
assert!(
!csp.contains("allow-same-origin"),
"{name} must never get allow-same-origin: {csp}"
);
assert!(
!csp.contains("allow-top-navigation ") && !csp.contains("allow-popups-to-escape"),
"{name}: {csp}"
);
// Still rendered as a document, not downloaded.
assert!(
r.header("content-disposition")
.unwrap()
.starts_with("inline")
);
}
// A non-scriptable file keeps the app policy (no sandbox at all).
let r = admin
.get(&format!("{}?action=preview", root_path("blob.bin")))
.await;
let csp = r.header("content-security-policy").unwrap();
assert!(!csp.contains("sandbox"), "{csp}");
assert!(
csp.contains("wasm-unsafe-eval"),
"expected app policy: {csp}"
);
// And the app's own pages are untouched by the `if_not_present` switch.
let r = admin.get("/").await;
let csp = r.header("content-security-policy").unwrap();
assert!(
csp.contains("wasm-unsafe-eval") && !csp.contains("sandbox"),
"{csp}"
);
}
▾Mweb/cm/wrapper.js
@@ -37,25 +37,11 @@ const EXT_TO_LANG = {
php: "php",
};
// Extensions that we treat as text (previewable in CodeMirror). Kept here so
// the preview type and the language come from one place.
const TEXT_EXTS = new Set([
...Object.keys(EXT_TO_LANG),
// plain text / config / markup without a dedicated grammar
"txt", "text", "log", "csv", "tsv", "ini", "cfg", "conf", "toml", "yml", "yaml", "env", "properties", "gradle", "dockerfile", "sh", "bash", "zsh",
"fish", "bat", "ps1", "vb", "cs", "go", "kt", "kts", "swift", "dart", "lua",
"r", "pl", "pm", "perl", "rb", "ex", "exs", "erl", "hs", "ml", "mli", "zig",
"nim", "v", "proto", "thrift", "graphql", "gql", "twig", "ejs", "hbs", "vue",
"svelte", "dockerfile", "makefile", "gitignore", "gitattributes", "editorconfig",
]);
// Common text files with no extension.
const EXTLESS_TEXT = new Set([
"dockerfile", "makefile", "justfile", "readme", "license", "licence",
"authors", "changelog", "contributing", "code_of_conduct",
"procfile", "brewfile", "gemfile", "rakefile", "vagrantfile",
"gitignore", "gitattributes", "editorconfig", "env.example",
]);
// Note: there is deliberately no extension list for "is this text" here. The
// server sniffs each file's leading bytes and reports a FileKind, so that
// question is answered once, from content, before the client ever sees it.
// This file only maps an extension to a *grammar*, which content sniffing
// cannot do (a .h is C or C++).
function langFromFilename(filename) {
const base = (filename || "").toLowerCase();
@@ -101,15 +87,6 @@ const appTheme = EditorView.theme({
window.__fbng_cm = {
version: 1,
// Is this filename text (previewable in the editor)?
isText(filename) {
const base = ((filename || "").toLowerCase().split("/").pop() || "").trim();
if (EXTLESS_TEXT.has(base)) return true;
const dot = base.lastIndexOf(".");
const ext = dot >= 0 ? base.slice(dot + 1) : "";
return TEXT_EXTS.has(ext);
},
// Create an editor in `container`. Returns an opaque view handle.
// opts: { value, filename, editable, onUpdate? }
create(container, opts) {
▾Mweb/src/api.rs
@@ -405,6 +405,18 @@ pub async fn save_content(
Ok(save.mtime)
}
/// Open a URL in a new tab.
///
/// `noopener` severs the `window.opener` link, so the opened page cannot
/// script this one. That matters here because the target is a *user file*:
/// HTML and SVG render as real documents (under the server's sandbox CSP, see
/// `FILE_CSP`), and this is the browser-side half of the same isolation.
pub fn open_in_new_tab(url: &str) {
if let Some(w) = web_sys::window() {
let _ = w.open_with_url_and_target_and_features(url, "_blank", "noopener");
}
}
/// Trigger a browser download of a same-origin URL via a temporary anchor.
/// No data is pulled into JS memory — the browser streams it.
pub fn trigger_download(url: &str, filename: &str) {
▾Mweb/src/cm.rs
@@ -43,16 +43,6 @@ fn call(g: &JsValue, method: &str, args: &[&JsValue]) -> Result<JsValue, String>
js_sys::Reflect::apply(&f, g, &arr).map_err(err_str)
}
/// Can this file name be shown in the text editor? (Delegated to the bundle so
/// the extension list lives in one place.)
pub fn is_text(name: &str) -> bool {
global()
.ok()
.and_then(|g| call(&g, "isText", &[&JsValue::from_str(name)]).ok())
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Create an editor inside `container`.
///
/// `on_update` is an optional JS function called whenever the document changes
▾Mweb/src/components/icon.rs
@@ -1,3 +1,4 @@
use api_types::FileKind;
use leptos::prelude::*;
#[derive(Clone, Copy, PartialEq, Debug)]
@@ -27,24 +28,35 @@ pub enum IconName {
Trash,
}
/// Pick the icon for a file name based on its extension.
pub fn icon_for(name: &str, is_dir: bool) -> IconName {
if is_dir {
return IconName::Folder;
}
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "avif" => IconName::Image,
"mp4" | "webm" | "mkv" | "mov" | "avi" => IconName::Video,
"mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" => IconName::Audio,
"pdf" => IconName::Pdf,
"zip" | "tar" | "gz" | "tgz" | "zst" | "bz2" | "xz" | "7z" | "rar" => IconName::Archive,
"rs" | "js" | "ts" | "tsx" | "jsx" | "py" | "rb" | "go" | "c" | "cpp" | "h" | "hpp"
| "json" | "toml" | "yaml" | "yml" | "sh" | "html" | "css" | "wasm" | "sql" => {
IconName::Code
/// Extensions that get the "code" icon instead of the plain-text one. Both are
/// [`FileKind::Text`] to the server — code-vs-prose is a naming question, not a
/// content one, so it stays here.
const CODE_EXTS: &[&str] = &[
"rs", "js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "rb", "go", "c", "cc", "cpp", "h", "hpp",
"java", "kt", "cs", "php", "swift", "lua", "sh", "bash", "fish", "zsh", "ps1", "json", "toml",
"yaml", "yml", "html", "htm", "css", "scss", "sql", "xml", "vue", "svelte",
];
/// Pick the icon for an entry from its server-sniffed [`FileKind`].
///
/// `name` is used only to split [`FileKind::Text`] into prose and code.
pub fn icon_for(kind: FileKind, name: &str) -> IconName {
match kind {
FileKind::Dir => IconName::Folder,
FileKind::Image => IconName::Image,
FileKind::Video => IconName::Video,
FileKind::Audio => IconName::Audio,
FileKind::Pdf => IconName::Pdf,
FileKind::Archive => IconName::Archive,
FileKind::Text => {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
if CODE_EXTS.contains(&ext.as_str()) {
IconName::Code
} else {
IconName::Text
}
}
"txt" | "md" | "log" | "csv" | "ini" | "conf" | "xml" => IconName::Text,
_ => IconName::File,
FileKind::Binary => IconName::File,
}
}
▾Mweb/src/preview.rs
@@ -3,6 +3,7 @@
use std::sync::{Arc, Mutex};
use api_types::FileKind;
use leptos::prelude::*;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::spawn_local;
@@ -21,36 +22,17 @@ pub enum PreviewKind {
Text,
}
const IMAGE_EXTS: &[&str] = &[
"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "avif", "tiff", "tif",
];
const VIDEO_EXTS: &[&str] = &["mp4", "webm", "ogv", "mov", "m4v"];
const AUDIO_EXTS: &[&str] = &["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"];
fn ext_of(name: &str) -> String {
let base = name.rsplit('/').next().unwrap_or(name);
base.rsplit('.').next().unwrap_or("").to_lowercase()
}
/// Decide how to preview a file by name. `None` = no preview (download only).
pub fn preview_kind(name: &str) -> Option<PreviewKind> {
let ext = ext_of(name);
if IMAGE_EXTS.contains(&ext.as_str()) {
return Some(PreviewKind::Image);
}
if ext == "pdf" {
return Some(PreviewKind::Pdf);
}
if VIDEO_EXTS.contains(&ext.as_str()) {
return Some(PreviewKind::Video);
}
if AUDIO_EXTS.contains(&ext.as_str()) {
return Some(PreviewKind::Audio);
}
if cm::is_text(name) {
return Some(PreviewKind::Text);
/// Which viewer opens this entry, from the server's sniffed [`FileKind`].
/// `None` = nothing we can show in the browser (download only).
pub fn preview_kind(kind: FileKind) -> Option<PreviewKind> {
match kind {
FileKind::Image => Some(PreviewKind::Image),
FileKind::Pdf => Some(PreviewKind::Pdf),
FileKind::Video => Some(PreviewKind::Video),
FileKind::Audio => Some(PreviewKind::Audio),
FileKind::Text => Some(PreviewKind::Text),
FileKind::Dir | FileKind::Archive | FileKind::Binary => None,
}
None
}
/// A file the user wants to preview.
▾Mweb/src/views/browser.rs
@@ -1,11 +1,11 @@
//! The file browser: breadcrumbs, grid/list views, root picker, context menu.
use api_types::FileKind;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, Entry, Me, RootInfo};
use crate::cm;
use crate::components::icon::{Icon, IconName, icon_for};
use crate::components::toast::{ToastMsg, show};
use crate::editor::{EditTarget, EditorModal};
@@ -416,6 +416,43 @@ fn entry_actions(
(nav, ctx_handler)
}
/// Click/Enter on an entry: descend into a folder, or open the right viewer for
/// a file. Shared by the grid and the list, which differ only in markup.
fn open_callback(
entry: &Entry,
root_id: i64,
loc: &ReadSignal<Location>,
set_ctx: &WriteSignal<Option<CtxMenu>>,
toast: &ToastMsg,
set_preview: &WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
) -> (Callback<()>, impl Fn(web_sys::MouseEvent) + 'static) {
let (nav, on_ctx) = entry_actions(entry.clone(), root_id, loc, set_ctx, toast);
let (t, l, sp) = (*toast, *loc, *set_preview);
let (name, is_dir, size, kind) = (entry.name.clone(), entry.is_dir, entry.size, entry.kind);
let cb = Callback::new(move |_| {
if is_dir {
nav();
return;
}
match preview_kind(kind) {
Some(k) => {
let full = join_path(&l.get().path, &name);
sp.set(Some((
PreviewTarget {
root_id,
path: full,
name: name.clone(),
size,
},
k,
)));
}
None => show(t, "No preview available — right-click to download"),
}
});
(cb, on_ctx)
}
fn grid_view(
entries: &[Entry],
root_id: i64,
@@ -427,37 +464,11 @@ fn grid_view(
view! {
<div class="entries-grid">
{entries.iter().map(|e| {
let icon = icon_for(&e.name, e.is_dir);
let icon = icon_for(e.kind, &e.name);
let name = e.name.clone();
let (nav, on_ctx) = entry_actions(e.clone(), root_id, loc, set_ctx, toast);
let t = *toast;
let is_dir = e.is_dir;
let title = name.clone();
let size = e.size;
let l = *loc;
let sp = *set_preview;
let name2 = name.clone();
let open_cb: Callback<()> = Callback::new(move |_| {
if is_dir {
nav();
} else {
match preview_kind(&name2) {
Some(k) => {
let full = join_path(&l.get().path, &name2);
sp.set(Some((
PreviewTarget {
root_id,
path: full,
name: name2.clone(),
size,
},
k,
)));
}
None => show(t, "No preview available — right-click to download"),
}
}
});
let (open_cb, on_ctx) =
open_callback(e, root_id, loc, set_ctx, toast, set_preview);
let oc1 = open_cb;
let oc2 = open_cb;
view! {
@@ -494,39 +505,14 @@ fn list_view(
view! {
<div class="entries-list">
{entries.iter().map(|e| {
let icon = icon_for(&e.name, e.is_dir);
let icon = icon_for(e.kind, &e.name);
let name = e.name.clone();
let size = format_size(e.size);
let date = format_date(&e.mtime);
let (nav, on_ctx) = entry_actions(e.clone(), root_id, loc, set_ctx, toast);
let t = *toast;
let is_dir = e.is_dir;
let title = name.clone();
let size_bytes = e.size;
let l = *loc;
let sp = *set_preview;
let name2 = name.clone();
let open_cb: Callback<()> = Callback::new(move |_| {
if is_dir {
nav();
} else {
match preview_kind(&name2) {
Some(k) => {
let full = join_path(&l.get().path, &name2);
sp.set(Some((
PreviewTarget {
root_id,
path: full,
name: name2.clone(),
size: size_bytes,
},
k,
)));
}
None => show(t, "No preview available — right-click to download"),
}
}
});
let (open_cb, on_ctx) =
open_callback(e, root_id, loc, set_ctx, toast, set_preview);
let oc1 = open_cb;
let oc2 = open_cb;
view! {
@@ -647,6 +633,17 @@ fn CtxMenuView(
None,
));
}
// Serve the file itself in a new tab. HTML and SVG render
// as real pages there (sandboxed by the server), which is
// how you share a small site or an HTML report.
if !e2.is_dir {
let e_tab = e2.clone();
v.push((
"Open in new tab".into(),
Some(action_open_tab(&e_tab, eff_root_id, loc, owner.clone())),
None,
));
}
let e_dl = e2.clone();
let l_dl = loc;
let t_dl = toast;
@@ -655,8 +652,8 @@ fn CtxMenuView(
Some(action_download(&e_dl, eff_root_id, l_dl, set_dialog, t_dl, owner.clone())),
None,
));
// Editing is only offered for text/code files.
if !e2.is_dir && cm::is_text(&e2.name) {
// Editing is only offered for files that decode as text.
if e2.kind == FileKind::Text {
let e_ed = e2.clone();
let l_ed = loc;
v.push((
@@ -1203,6 +1200,24 @@ fn action_download(
})
}
/// Open the file itself in a new tab, via the `?action=preview` endpoint —
/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
fn action_open_tab(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
owner.with(|| {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let full = join_path(&loc.get().path, &name);
api::open_in_new_tab(&api::preview_url(root_id, &full));
})
})
}
/// Open the text editor for a file (milestone 5).
fn action_edit(
entry: &Entry,