grid thumbnails for images and videos
Add a thumbnail cache behind `?action=thumb`, so grid view shows a small preview instead of an icon. Off until `--cache` names a folder. Each user can turn it off under Settings, Profile. Images decode in-process: `image` to read, `fast_image_resize` for a Lanczos3 downscale, libwebp to encode. WebP at quality 80 because at thumbnail size it is 18% smaller than JPEG at equal quality, and the two formats that beat it are not decoded by every browser. Videos go through `ffmpeg`, which is optional: without it images still get thumbnails. The cache key is the source path, its size and its mtime, so an edited file gets a new entry and the old one ages out on its own. An entry unused for a day is swept, and every hit refreshes its mtime. Not atime: `relatime` updates it once a day and `noatime` mounts never do. The client puts the mtime in the URL, which lets the response be immutable. A revisited folder then costs no requests at all. Generation is capped at `cpus.min(8)`, where measured throughput stops improving. One decode is refused past 100 MP. A file that fails to decode is remembered, so a corrupt image is not retried on every visit. Alpha is flattened onto white. Dropping the channel instead keeps the colour behind it, which for a cut-out background is black. The container image now carries ffmpeg. That is 122 MiB of codec libraries against 8 MiB for everything else, and buying only video. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MCargo.lock
@@ -303,6 +303,18 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -404,6 +416,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -557,6 +575,12 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -648,6 +672,15 @@ dependencies = [
"syn 3.0.4",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "drain_filter_polyfill"
version = "0.1.3"
@@ -748,12 +781,41 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fast_image_resize"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc7fe45cf92b43817ff62a3723e862b85bd1d06288f63007f7645d1d2f7a060"
dependencies = [
"bytemuck",
"cfg-if",
"document-features",
"image",
"num-traits",
"thiserror 2.0.20",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "filetime"
version = "0.2.29"
@@ -777,7 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"miniz_oxide 0.9.1",
"zlib-rs",
]
@@ -918,6 +980,22 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "globset"
version = "0.4.20"
@@ -1062,6 +1140,17 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1374,6 +1463,35 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.1"
@@ -1606,6 +1724,16 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libwebp-sys"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733"
dependencies = [
"cc",
"glob",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -1618,6 +1746,12 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "log"
version = "0.4.34"
@@ -1693,6 +1827,16 @@ dependencies = [
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -1714,6 +1858,16 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -1852,6 +2006,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
@@ -1935,6 +2102,18 @@ dependencies = [
"yansi",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.47"
@@ -2312,12 +2491,14 @@ dependencies = [
"chrono",
"clap",
"dav-server",
"fast_image_resize",
"flate2",
"futures-util",
"grep",
"headers",
"http-body-util",
"ignore",
"image",
"infer",
"mime_guess",
"multer",
@@ -2336,6 +2517,7 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"webp",
"xmltree",
"zip",
"zstd",
@@ -2692,6 +2874,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -3133,6 +3329,22 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webp"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1"
dependencies = [
"image",
"libwebp-sys",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -3420,3 +3632,18 @@ dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
MContainerfile
@@ -37,8 +37,10 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
# ── runtime ──────────────────────────────────────────────────────────────────
FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache ca-certificates \
&& mkdir -p /data /var/lib/filebrowser
# ffmpeg is only for video thumbnails, and it is most of the image: it pulls
# ~120 MiB of codec libraries against 8 MiB for the rest
RUN apk add --no-cache ca-certificates ffmpeg \
&& mkdir -p /data /var/lib/filebrowser /var/cache/filebrowser
COPY --from=build /src/filebrowser-ng /usr/local/bin/filebrowser-ng
MREADME.md
@@ -14,6 +14,7 @@ external services.
`tar.zst`, streamed without a temporary file.
- **Preview**: images, video, audio, and PDF inline. Video and audio support
range requests, so seeking works.
- **Thumbnails**: images and videos show a small preview in grid view.
- **Text editor**: CodeMirror 6 with syntax highlighting for common
languages. Saves back to disk in writable folders.
- **Search**: by name, by content, or both. Results stream in as they are
@@ -70,6 +71,7 @@ filebrowser-ng --root /srv/files --db /var/lib/filebrowser/db.sqlite --bind 0.0.
| `--bind` | `127.0.0.1` | Listen address. `0.0.0.0` exposes the server beyond localhost. |
| `--https` | off | Set when behind a TLS-terminating proxy. Marks the cookie `Secure`. Also `FILEBROWSER_HTTPS=true`. |
| `--root-name` | folder name | Display name of the root folder. Also `FILEBROWSER_ROOT_NAME`. |
| `--cache` | off | Folder for the thumbnail cache. Turns thumbnails on. Also `FILEBROWSER_CACHE`. |
Log level comes from `RUST_LOG` (`error`, `warn`, `info`, `debug`, `trace`).
@@ -80,6 +82,23 @@ pass `--https`, or set `FILEBROWSER_HTTPS=true` in `compose.yml`. Without the fl
as well. Search uses server-sent events, so the proxy must not buffer
responses on `/api/search`.
## Thumbnails
Grid view shows a small preview of an image or video instead of an icon.
Point `--cache` at a folder to turn them on:
```bash
filebrowser-ng --root /srv/files --db /var/lib/filebrowser/db.sqlite \
--cache /var/cache/filebrowser
```
Notes:
- Videos need `ffmpeg` on the server. The container image ships with it.
Without it, images still get thumbnails and videos keep their icon.
- The cache is disposable. You can delete the folder at any time; the server
makes the thumbnails again as they are needed.
## WebDAV
Two mount points. Your permissions are the same as in the web UI.
Mapi-types/src/lib.rs
@@ -50,6 +50,7 @@ pub const P_ACTION: &str = "action";
pub const ACTION_DOWNLOAD: &str = "download";
pub const ACTION_PREVIEW: &str = "preview";
pub const ACTION_CONTENT: &str = "content";
pub const ACTION_THUMB: &str = "thumb";
/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
/// POST route also carries uploads and mutations.
pub const ACTION_MKDIR: &str = "mkdir";
@@ -308,6 +309,8 @@ pub struct UserInfo {
/// Profile setting: single click opens entries (off = click selects,
/// double click opens).
pub single_click_open: bool,
/// Profile setting: show image and video thumbnails in the grid.
pub thumbnails: bool,
/// Preferred UI language tag ("en", "de", "fr"); None = follow the
/// browser.
pub language: Option<String>,
@@ -330,6 +333,9 @@ pub struct Me {
pub user: Option<UserInfo>,
pub roots: Vec<RootInfo>,
pub allow_writable_shares: bool,
/// Whether the server can make thumbnails at all (`--cache` is set).
/// The profile setting is only offered when this is true.
pub thumbnails_available: bool,
}
/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
@@ -463,6 +469,7 @@ mod tests {
name: "admin".into(),
is_admin: true,
single_click_open: false,
thumbnails: true,
language: None,
}),
roots: vec![RootInfo {
@@ -472,6 +479,7 @@ mod tests {
mode: Mode::Rw,
}],
allow_writable_shares: false,
thumbnails_available: true,
};
let s = serde_json::to_string(&me).unwrap();
let back: Me = serde_json::from_str(&s).unwrap();
Mcompose.yml
@@ -8,6 +8,8 @@ services:
RUST_LOG: info # server log level: trace/debug/info/warn/error
# FILEBROWSER_HTTPS: "true" # behind a TLS-terminating proxy: marks the session cookie Secure
# FILEBROWSER_ROOT_NAME: Media # UI name of the root folder (default: its folder name, here "data")
# FILEBROWSER_CACHE: /var/cache/filebrowser # turns grid thumbnails on; ffmpeg is in the image
volumes:
- ./data:/data # browsed files (server --root)
- ./filebrowser-db:/var/lib/filebrowser # sqlite db: users, shares, settings
# - ./filebrowser-cache:/var/cache/filebrowser # optional: keeps thumbnails across restarts
Mjustfile
@@ -2,6 +2,7 @@
dev-root := '.dev/root'
dev-db := '.dev/db.sqlite'
dev-cache := '.dev/cache'
# Show the available recipes (runs when you type `just` without arguments).
default:
@@ -17,7 +18,7 @@ dev: dev-data dev-server
# Dev backend on :8081 — the API plus static files from web/dist (no HMR).
dev-server:
cargo run -p server -- --root {{dev-root}} --db {{dev-db}} --port 8081
cargo run -p server -- --root {{dev-root}} --db {{dev-db}} --cache {{dev-cache}} --port 8081
# Frontend dev server on :8080 (Trunk with HMR; proxies /api to :8081).
# Trunk strips the /api rewrite prefix, so the backend URL carries it back.
@@ -47,7 +48,7 @@ build-cm:
# Run the production binary against the dev folders.
run: dev-data build
./target/release/filebrowser-ng --root {{dev-root}} --db {{dev-db}}
./target/release/filebrowser-ng --root {{dev-root}} --db {{dev-db}} --cache {{dev-cache}}
# Server test suite (unit + API integration tests).
test:
Mserver/Cargo.toml
@@ -20,6 +20,21 @@ clap = { version = "4", features = ["derive", "env"] }
# dav filesystem is `api::dav`, which goes through `crate::fs` so that root
# containment, read-only roots and share scoping apply to a mount too.
dav-server = { version = "0.11", default-features = false }
# Thumbnails: decode only what the grid can show. The default feature set
# also pulls in rav1e and exr, which are large and unused here.
image = { version = "0.25", default-features = false, features = [
"jpeg",
"png",
"gif",
"webp",
"bmp",
"tiff",
] }
# Lanczos3 downscaling. `image`'s own is 15x slower at the same quality.
fast_image_resize = { version = "5", features = ["image"] }
# libwebp for the encoder. The crate vendors the C source and builds it with
# `cc`; no system library, same as the bundled SQLite.
webp = "0.3"
# `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"] }
@@ -39,6 +54,7 @@ tokio = { version = "1", features = [
"sync",
"time",
"signal",
"process",
] }
tar = "0.4"
flate2 = "1"
Mserver/src/api/auth.rs
@@ -30,6 +30,7 @@ pub async fn me(
user: None,
roots: Vec::new(),
allow_writable_shares: false,
thumbnails_available: state.thumbs.is_some(),
}));
}
@@ -56,10 +57,12 @@ async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me
name: user.name.clone(),
is_admin: user.is_admin,
single_click_open: user.single_click,
thumbnails: user.thumbnails,
language: user.language.clone(),
}),
roots,
allow_writable_shares: state.db.allow_writable_shares().await?,
thumbnails_available: state.thumbs.is_some(),
})
}
@@ -72,6 +75,7 @@ async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me
pub(crate) struct ProfilePatch {
#[serde(default)]
pub single_click_open: Option<bool>,
pub thumbnails: Option<bool>,
#[serde(default, deserialize_with = "language_field")]
pub language: Option<Option<String>>,
}
@@ -117,6 +121,10 @@ pub async fn update_profile(
state.db.set_user_single_click(user.id, v).await?;
user.single_click = v;
}
if let Some(v) = body.thumbnails {
state.db.set_user_thumbnails(user.id, v).await?;
user.thumbnails = v;
}
if let Some(lang) = body.language {
state.db.set_user_language(user.id, lang.as_deref()).await?;
user.language = lang;
Mserver/src/api/files.rs
@@ -95,6 +95,7 @@ pub async fn file_get(
.await
}
Some(a) if a == api_types::ACTION_CONTENT => content(state, auth, root_id, req_rel).await,
Some(a) if a == api_types::ACTION_THUMB => thumb(state, auth, root_id, req_rel).await,
_ => {
let json = list_inner(state, auth, root_id, req_rel).await?;
Ok(json.into_response())
@@ -366,6 +367,56 @@ async fn content(
.into_response())
}
/// `GET ...?action=thumb` — a small WebP preview of an image or video.
///
/// `404` covers every "no thumbnail here" case: thumbnails switched off, a
/// folder, a kind we do not render, an undecodable file, a video without
/// ffmpeg. The grid falls back to its icon for all of them alike.
async fn thumb(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
) -> Result<Response, ApiError> {
let Some(thumbs) = state.thumbs.as_ref() else {
return Err(no_thumb());
};
let root = find_root(&auth.roots, root_id)?;
let (full, _name, is_dir, size, mtime) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if is_dir {
return Err(no_thumb());
}
// The kind is sniffed from the bytes, not the name, so an mp4 called .txt
// still gets a thumbnail and a .jpg full of text does not.
let kind = {
let full = full.clone();
blocking(move || Ok::<_, FsError>(fs::detect_kind(&full, false))).await?
};
let Some(bytes) = thumbs.get(&full, kind, size, mtime).await else {
return Err(no_thumb());
};
Ok((
[
(header::CONTENT_TYPE, "image/webp"),
// Safe despite the stable path: the client varies the query on
// mtime, so new content always means a new URL.
(
header::CACHE_CONTROL,
"private, max-age=31536000, immutable",
),
],
bytes,
)
.into_response())
}
/// The message never reaches a user: an `<img>` 404 just leaves the tile's
/// icon showing. That is why it carries no localized code.
fn no_thumb() -> ApiError {
ApiError::new(StatusCode::NOT_FOUND, "no thumbnail".to_string())
}
/// `PUT ...?action=content` — save a file's text contents (the editor).
///
/// Requires a read-write root. The body is the new contents. If the
Mserver/src/cli.rs
@@ -31,6 +31,11 @@ pub struct Cli {
#[arg(long, default_value = "127.0.0.1")]
pub bind: String,
/// Folder for the thumbnail cache. Without it thumbnails are off and
/// the grid shows icons only. Entries not used for a day are swept.
#[arg(long, env = "FILEBROWSER_CACHE")]
pub cache: Option<PathBuf>,
/// Assume the server runs behind a TLS-terminating reverse proxy
/// (sets the `Secure` attribute on the session cookie).
/// The env form takes `true` or `false`.
@@ -55,6 +60,7 @@ mod tests {
assert_eq!(c.db, PathBuf::from("/d"));
assert_eq!(c.port, 8080);
assert_eq!(c.bind, "127.0.0.1");
assert_eq!(c.cache, None);
assert!(!c.https);
}
Mserver/src/db.rs
@@ -5,7 +5,7 @@ pub use api_types::Mode;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA_VERSION: i64 = 7;
const SCHEMA_VERSION: i64 = 8;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
@@ -37,6 +37,8 @@ pub struct User {
pub active: bool,
/// Profile setting: single click opens entries (off = click selects).
pub single_click: bool,
/// Profile setting: show thumbnails in the grid.
pub thumbnails: bool,
/// Preferred UI language tag ("en", "de", "fr"); None = follow the
/// browser.
pub language: Option<String>,
@@ -183,6 +185,13 @@ impl Db {
ON share_unlocks(share_id)",
)?;
}
if version < 8 {
// On by default, so `--cache` is the only step needed to get
// thumbnails.
conn.execute_batch(
"ALTER TABLE users ADD COLUMN thumbnails INTEGER NOT NULL DEFAULT 1",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -227,6 +236,7 @@ impl Db {
is_admin: true,
active: true,
single_click: false,
thumbnails: true,
language: None,
}))
}
@@ -235,12 +245,12 @@ impl Db {
// The guard is scoped to the query alone. Argon2 below is slow by
// design; holding the single connection lock across it would make one
// login serialize every other database access.
type UserRow = (i64, String, bool, String, bool, bool, Option<String>);
type UserRow = (i64, String, bool, String, bool, bool, bool, Option<String>);
let row: Option<UserRow> = {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, pass_hash, active != 0, single_click != 0,
language
thumbnails != 0, language
FROM users WHERE name = ?1",
[name],
|r| {
@@ -252,6 +262,7 @@ impl Db {
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
))
},
)
@@ -260,13 +271,18 @@ impl Db {
// An unknown or disabled name still pays for one Argon2 verify, so the
// response time does not reveal which names exist.
let (row, hash) = match row {
Some((id, name, is_admin, hash, active, single_click, language)) if active => {
(Some((id, name, is_admin, single_click, language)), hash)
Some((id, name, is_admin, hash, active, single_click, thumbnails, language))
if active =>
{
(
Some((id, name, is_admin, single_click, thumbnails, language)),
hash,
)
}
_ => (None, DUMMY_HASH.clone()),
};
let ok = crate::auth::verify_password_async(password, &hash).await;
let Some((id, name, is_admin, single_click, language)) = row else {
let Some((id, name, is_admin, single_click, thumbnails, language)) = row else {
return Ok(None);
};
Ok(ok.then_some(User {
@@ -275,6 +291,7 @@ impl Db {
is_admin,
active: true,
single_click,
thumbnails,
language,
}))
}
@@ -305,7 +322,8 @@ impl Db {
) -> DbResult<Option<(User, Vec<RootRow>)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0, u.language,
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
u.thumbnails != 0, u.language,
r.id, r.path, r.mode
FROM sessions s
JOIN users u ON u.id = s.user_id
@@ -322,11 +340,11 @@ impl Db {
if user.is_none() {
user = Some(map_user(r)?);
}
if let Some(id) = r.get::<_, Option<i64>>(6)? {
if let Some(id) = r.get::<_, Option<i64>>(7)? {
roots.push(RootRow {
id,
path: r.get(7)?,
mode: r.get::<_, SqlMode>(8)?.0,
path: r.get(8)?,
mode: r.get::<_, SqlMode>(9)?.0,
});
}
}
@@ -357,7 +375,8 @@ impl Db {
pub async fn all_users_with_roots(&self) -> DbResult<Vec<(User, Vec<RootRow>)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0, u.language,
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
u.thumbnails != 0, u.language,
r.id, r.path, r.mode
FROM users u
LEFT JOIN user_roots r ON r.user_id = u.id
@@ -371,11 +390,11 @@ impl Db {
if out.last().is_none_or(|(u, _)| u.id != uid) {
out.push((map_user(r)?, Vec::new()));
}
if let Some(id) = r.get::<_, Option<i64>>(6)? {
if let Some(id) = r.get::<_, Option<i64>>(7)? {
out.last_mut().expect("pushed above").1.push(RootRow {
id,
path: r.get(7)?,
mode: r.get::<_, SqlMode>(8)?.0,
path: r.get(8)?,
mode: r.get::<_, SqlMode>(9)?.0,
});
}
}
@@ -385,7 +404,8 @@ impl Db {
pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, language
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, thumbnails != 0,
language
FROM users WHERE id = ?1",
[id],
map_user,
@@ -396,7 +416,8 @@ impl Db {
pub async fn find_user_by_name(&self, name: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, language
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, thumbnails != 0,
language
FROM users WHERE name = ?1",
[name],
map_user,
@@ -442,6 +463,7 @@ impl Db {
is_admin,
active: true,
single_click: false,
thumbnails: true,
language: None,
})
}
@@ -455,6 +477,15 @@ impl Db {
Ok(())
}
pub async fn set_user_thumbnails(&self, id: i64, thumbnails: bool) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET thumbnails = ?1 WHERE id = ?2",
params![thumbnails as i64, id],
)?;
Ok(())
}
pub async fn set_user_language(&self, id: i64, language: Option<&str>) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
@@ -716,7 +747,8 @@ fn map_user(r: &rusqlite::Row) -> DbResult<User> {
is_admin: r.get(2)?,
active: r.get(3)?,
single_click: r.get(4)?,
language: r.get(5)?,
thumbnails: r.get(5)?,
language: r.get(6)?,
})
}
Mserver/src/error.rs
@@ -1,4 +1,5 @@
use std::path::PathBuf;
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
@@ -14,6 +15,9 @@ pub struct AppState {
pub root_name: String,
/// Whether we sit behind a TLS-terminating reverse proxy.
pub https: bool,
/// Thumbnail cache. `None` when `--cache` is unset, which turns
/// thumbnails off everywhere.
pub thumbs: Option<Arc<crate::thumb::Thumbs>>,
}
/// API error. `extra` is optionally merged into the JSON body (e.g. a list of
Mserver/src/lib.rs
@@ -19,6 +19,7 @@ pub mod cli;
pub mod db;
pub mod error;
pub mod fs;
pub mod thumb;
use crate::cli::Cli;
use crate::db::Db;
@@ -42,11 +43,24 @@ pub async fn build_app(cli: &Cli) -> anyhow::Result<(axum::Router, SocketAddr)>
.clone()
.filter(|n| !n.trim().is_empty())
.unwrap_or_else(|| root_file_name(&root));
let thumbs = match &cli.cache {
Some(dir) => Some(Arc::new(
crate::thumb::Thumbs::new(dir.clone())
.await
.with_context(|| format!("cannot use thumbnail cache: {}", dir.display()))?,
)),
None => None,
};
if let Some(dir) = cli.cache.clone() {
tokio::spawn(crate::thumb::sweep_forever(dir));
}
let state = Arc::new(AppState {
db,
root: root.clone(),
root_name,
https: cli.https,
thumbs,
});
let app = api::router(state).layer(TraceLayer::new_for_http());
@@ -126,6 +140,7 @@ mod tests {
root_name: None,
port: 8080,
bind: "127.0.0.1".into(),
cache: None,
https: false,
}
}
Aserver/src/thumb.rs
@@ -0,0 +1,484 @@
//! Thumbnail cache for the grid view.
//!
//! Images decode in-process. Videos go through `ffmpeg`, which is optional.
//! Nothing here writes to the database: the cache is disposable.
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use api_types::FileKind;
use fast_image_resize::images::Image as FirImage;
use fast_image_resize::{FilterType, IntoImageView, ResizeAlg, ResizeOptions, Resizer};
use image::{DynamicImage, ImageReader, Limits, RgbImage};
use sha2::{Digest, Sha256};
use tokio::sync::Semaphore;
/// Longest edge of a thumbnail, in pixels. The grid tile is 150 CSS px, so
/// this still looks sharp on a 2x display.
const MAX_EDGE: u32 = 256;
/// WebP quality. At this size WebP is 18% smaller than JPEG at equal quality.
/// AVIF and JXL are smaller still, but not every browser decodes them.
const QUALITY: f32 = 80.0;
/// An entry unused for this long is swept. Every hit refreshes the mtime, so
/// this is "cold for a day", not "made a day ago".
const TTL: Duration = Duration::from_secs(24 * 60 * 60);
/// Only refresh an entry's mtime when it is at least this stale. Without the
/// guard, one grid of 100 tiles is 100 metadata writes on every visit.
const REFRESH_AFTER: Duration = Duration::from_secs(60 * 60);
/// How often the sweeper runs. Each pass walks the whole cache.
const SWEEP_EVERY: Duration = Duration::from_secs(60 * 60);
/// Refuse to decode an image with more pixels than this. A 100 MP RGB buffer
/// is 300 MB, so a crafted or merely enormous file could exhaust memory.
const MAX_PIXELS: u64 = 100_000_000;
/// Cap on the memory one decode may allocate. Backs up `MAX_PIXELS` for
/// formats whose real cost the header does not reveal.
const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024;
/// Seconds to seek into a video before grabbing a frame. The first second is
/// often black.
const VIDEO_SEEK: &str = "1";
pub struct Thumbs {
dir: PathBuf,
/// Caps concurrent generation. Measured throughput flattens past 8
/// workers, and the cap keeps one big folder from starving the server.
limit: Semaphore,
/// Whether `ffmpeg` answered at startup. Only video thumbnails need it.
ffmpeg: bool,
}
impl Thumbs {
/// Prepare the cache folder. Also probes for `ffmpeg` once, so that a
/// missing binary costs one failed spawn per process, not per video.
pub async fn new(dir: PathBuf) -> std::io::Result<Self> {
let ffmpeg = tokio::process::Command::new("ffmpeg")
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.is_ok_and(|s| s.success());
if !ffmpeg {
tracing::info!("ffmpeg not found: video thumbnails are off");
}
Self::with_ffmpeg(dir, ffmpeg).await
}
/// [`Thumbs::new`] with the probe result supplied. Lets a test take the
/// no-ffmpeg branch on a machine that has ffmpeg.
pub async fn with_ffmpeg(dir: PathBuf, ffmpeg: bool) -> std::io::Result<Self> {
std::fs::create_dir_all(&dir)?;
// Fail early and loudly rather than on the first thumbnail.
let probe = dir.join(".writable");
std::fs::write(&probe, b"")?;
let _ = std::fs::remove_file(&probe);
let workers = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(8);
tracing::info!(dir = %dir.display(), workers, ffmpeg, "thumbnail cache ready");
Ok(Self {
dir,
limit: Semaphore::new(workers),
ffmpeg,
})
}
/// The thumbnail for `src`, making it if the cache does not have it.
///
/// `None` means no thumbnail: an unsupported kind, a source too large to
/// decode, a broken file, or a video with no ffmpeg. A failure is cached
/// as an empty file, so a corrupt image is decoded only once.
pub async fn get(&self, src: &Path, kind: FileKind, size: u64, mtime: i64) -> Option<Vec<u8>> {
if !matches!(kind, FileKind::Image | FileKind::Video) {
return None;
}
let path = self.entry_path(src, size, mtime);
if let Some(hit) = read_hit(&path).await {
return hit;
}
// The permit is held across generation only, never across the disk
// read above: a cache hit must not queue behind a 36 MP decode.
let _permit = self.limit.acquire().await.ok()?;
// Another request may have finished it while we waited.
if let Some(hit) = read_hit(&path).await {
return hit;
}
let made = match kind {
// Not a property of this file, so nothing is cached for it.
// A marker here would outlive installing ffmpeg: every 404
// refreshes its mtime, so the sweeper would never drop it.
FileKind::Video if !self.ffmpeg => return None,
FileKind::Video => video_thumb(src).await,
_ => {
let src = src.to_path_buf();
tokio::task::spawn_blocking(move || image_thumb(&src))
.await
.ok()
.flatten()
}
};
if made.is_none() {
tracing::debug!(src = %src.display(), "no thumbnail");
}
// An empty file is the "we tried, it did not work" marker.
store(&path, made.as_deref().unwrap_or(&[])).await;
made
}
/// `<cache>/<first 2 hex>/<rest>.webp`.
///
/// The fan-out keeps one folder from collecting every thumbnail on the
/// server, which makes the sweeper's `read_dir` walk cheap.
fn entry_path(&self, src: &Path, size: u64, mtime: i64) -> PathBuf {
let mut h = Sha256::new();
h.update(src.as_os_str().as_encoded_bytes());
h.update(size.to_le_bytes());
h.update(mtime.to_le_bytes());
// Output settings are part of the identity: changing them must not
// serve thumbnails made under the old ones.
h.update(MAX_EDGE.to_le_bytes());
h.update(QUALITY.to_le_bytes());
let hex = hex(&h.finalize()[..16]);
self.dir.join(&hex[..2]).join(format!("{}.webp", &hex[2..]))
}
}
/// Read a cache entry, refreshing its mtime so the sweeper counts it as used.
///
/// `None` = not cached. `Some(None)` = cached failure.
async fn read_hit(path: &Path) -> Option<Option<Vec<u8>>> {
let bytes = tokio::fs::read(path).await.ok()?;
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || touch(&path));
Some((!bytes.is_empty()).then_some(bytes))
}
/// Mark an entry as used. Its mtime is the last-used time the sweeper reads.
///
/// Not atime: `relatime` updates it once a day and `noatime` mounts never do,
/// so a hot thumbnail would look cold. `File::set_times` accepts a read-only
/// handle.
fn touch(path: &Path) {
let Ok(file) = std::fs::File::open(path) else {
return;
};
let fresh = file
.metadata()
.and_then(|m| m.modified())
.is_ok_and(|m| m.elapsed().is_ok_and(|age| age < REFRESH_AFTER));
if fresh {
return;
}
let _ = file.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now()));
}
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// A scratch name for one `store` call.
///
/// Unique per call, not just per key. Two requests can generate the same
/// uncached file at once, and a shared name lets one truncate the other's
/// bytes before the rename publishes them as an empty failure marker.
fn tmp_path(path: &Path) -> PathBuf {
let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
path.with_extension(format!("{}.{seq}.tmp", std::process::id()))
}
/// Write an entry. Via a temporary file and a rename, so a reader never sees
/// a half-written thumbnail, and a crash leaves no truncated one behind.
async fn store(path: &Path, bytes: &[u8]) {
let Some(parent) = path.parent() else { return };
if tokio::fs::create_dir_all(parent).await.is_err() {
return;
}
let tmp = tmp_path(path);
if tokio::fs::write(&tmp, bytes).await.is_err() {
let _ = tokio::fs::remove_file(&tmp).await;
return;
}
if tokio::fs::rename(&tmp, path).await.is_err() {
let _ = tokio::fs::remove_file(&tmp).await;
}
}
/// Decode, downscale and encode one image. Blocking; run it off the reactor.
fn image_thumb(src: &Path) -> Option<Vec<u8>> {
let reader = ImageReader::open(src).ok()?.with_guessed_format().ok()?;
// Dimensions come from the header, so an oversized file is refused before
// anything is allocated for it.
let (w, h) = reader.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
}
let mut reader = ImageReader::open(src).ok()?.with_guessed_format().ok()?;
let mut limits = Limits::default();
limits.max_alloc = Some(MAX_DECODE_BYTES);
reader.limits(limits);
encode(&flatten(reader.decode().ok()?))
}
/// Drop the alpha channel by compositing over white.
///
/// `to_rgb8` alone keeps the colour behind the alpha, which for a transparent
/// PNG is usually black. That turns a cut-out logo into a black tile. White
/// matches an image viewer and reads in both themes.
fn flatten(img: DynamicImage) -> DynamicImage {
if !img.color().has_alpha() {
return DynamicImage::ImageRgb8(img.into_rgb8());
}
let src = img.into_rgba8();
let mut out = RgbImage::new(src.width(), src.height());
for (o, p) in out.pixels_mut().zip(src.pixels()) {
// Straight (not premultiplied) alpha: out = src*a + white*(1-a).
let a = u32::from(p.0[3]);
let over = |c: u8| ((u32::from(c) * a + 255 * (255 - a)) / 255) as u8;
*o = image::Rgb([over(p.0[0]), over(p.0[1]), over(p.0[2])]);
}
DynamicImage::ImageRgb8(out)
}
/// One frame from a video, via ffmpeg. The frame comes back as a PNG on
/// stdout, already scaled down, so nothing full-size is decoded in-process.
async fn video_thumb(src: &Path) -> Option<Vec<u8>> {
let frame = match grab_frame(src, Some(VIDEO_SEEK)).await {
Some(f) => Some(f),
// A video shorter than the seek point yields no frame. Retry from the
// start before giving up on it.
None => grab_frame(src, None).await,
}?;
let img = image::load_from_memory_with_format(&frame, image::ImageFormat::Png).ok()?;
encode(&flatten(img))
}
async fn grab_frame(src: &Path, seek: Option<&str>) -> Option<Vec<u8>> {
let mut cmd = tokio::process::Command::new("ffmpeg");
cmd.arg("-nostdin").args(["-v", "error"]);
if let Some(s) = seek {
// Before `-i`, so ffmpeg seeks on the container index instead of
// decoding its way there.
cmd.args(["-ss", s]);
}
let out = cmd
.arg("-i")
.arg(src)
.args(["-frames:v", "1"])
.args([
"-vf",
// `min` against the source size so a small clip is not enlarged,
// which is what `encode` does for an image.
&format!(
"scale='min({MAX_EDGE},iw)':'min({MAX_EDGE},ih)'\
:force_original_aspect_ratio=decrease"
),
])
.args(["-f", "image2pipe", "-c:v", "png", "-"])
.stdin(std::process::Stdio::null())
.output()
.await
.ok()?;
if !out.status.success() || out.stdout.is_empty() {
// `-v error` writes the reason here. Without this a video that stops
// working gives no clue why.
tracing::debug!(
src = %src.display(),
status = %out.status,
stderr = %String::from_utf8_lossy(&out.stderr).trim(),
"ffmpeg produced no frame"
);
return None;
}
Some(out.stdout)
}
/// Downscale to fit `MAX_EDGE` and encode as WebP.
///
/// Lanczos3 through `fast_image_resize`: 46 ms against 720 ms for `image`'s
/// own on a 36 MP source. `DynamicImage::thumbnail` is visibly softer.
fn encode(img: &DynamicImage) -> Option<Vec<u8>> {
let (w, h) = (img.width(), img.height());
let scale = (MAX_EDGE as f32 / w.max(h) as f32).min(1.0);
let (dw, dh) = (
((w as f32 * scale) as u32).max(1),
((h as f32 * scale) as u32).max(1),
);
let mut dst = FirImage::new(dw, dh, img.pixel_type()?);
Resizer::new()
.resize(
img,
&mut dst,
&ResizeOptions::new().resize_alg(ResizeAlg::Convolution(FilterType::Lanczos3)),
)
.ok()?;
let rgb = RgbImage::from_raw(dw, dh, dst.into_vec())?;
Some(
webp::Encoder::from_rgb(&rgb, dw, dh)
.encode(QUALITY)
.to_vec(),
)
}
/// Drop entries nobody has used for [`TTL`]. Runs until the process ends.
pub async fn sweep_forever(dir: PathBuf) {
loop {
tokio::time::sleep(SWEEP_EVERY).await;
let dir = dir.clone();
let removed = tokio::task::spawn_blocking(move || sweep(&dir)).await;
if let Ok(n) = removed
&& n > 0
{
tracing::debug!(removed = n, "swept cold thumbnails");
}
}
}
/// One pass over the cache. Returns how many entries it removed.
fn sweep(dir: &Path) -> usize {
let Ok(buckets) = std::fs::read_dir(dir) else {
return 0;
};
let mut removed = 0;
for bucket in buckets.flatten() {
let Ok(entries) = std::fs::read_dir(bucket.path()) else {
continue;
};
for e in entries.flatten() {
let cold = e
.metadata()
.and_then(|m| m.modified())
.is_ok_and(|m| m.elapsed().is_ok_and(|age| age > TTL));
if cold && std::fs::remove_file(e.path()).is_ok() {
removed += 1;
}
}
// An empty bucket is left in place: there are at most 256 of them and
// the next thumbnail landing there would only recreate it.
}
removed
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes.iter().fold(String::new(), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Put a file in a bucket with a chosen age.
fn aged(dir: &Path, name: &str, age: Duration) -> PathBuf {
let bucket = dir.join("ab");
std::fs::create_dir_all(&bucket).unwrap();
let p = bucket.join(name);
std::fs::write(&p, b"x").unwrap();
let when = SystemTime::now() - age;
std::fs::File::options()
.write(true)
.open(&p)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(when))
.unwrap();
p
}
#[test]
fn two_writes_of_one_entry_use_different_scratch_names() {
// A shared name would let the second `write` truncate the first one's
// bytes between its write and its rename, publishing an empty file.
// An empty entry reads back as a cached failure.
let p = Path::new("/cache/ab/cd.webp");
assert_ne!(tmp_path(p), tmp_path(p));
assert_ne!(tmp_path(p), p.to_path_buf());
}
#[test]
fn the_sweeper_drops_only_cold_entries() {
let dir = tempfile::tempdir().unwrap();
let hot = aged(dir.path(), "hot.webp", Duration::from_secs(60));
let warm = aged(dir.path(), "warm.webp", TTL - Duration::from_secs(600));
let cold = aged(dir.path(), "cold.webp", TTL + Duration::from_secs(600));
assert_eq!(sweep(dir.path()), 1);
assert!(hot.exists());
assert!(warm.exists());
assert!(!cold.exists());
}
#[test]
fn a_hit_refreshes_a_stale_entry_only() {
let dir = tempfile::tempdir().unwrap();
let stale = aged(
dir.path(),
"stale.webp",
REFRESH_AFTER + Duration::from_secs(60),
);
let fresh = aged(dir.path(), "fresh.webp", Duration::from_secs(30));
let fresh_before = std::fs::metadata(&fresh).unwrap().modified().unwrap();
touch(&stale);
touch(&fresh);
let age = std::fs::metadata(&stale)
.unwrap()
.modified()
.unwrap()
.elapsed()
.unwrap();
assert!(
age < Duration::from_secs(5),
"stale entry was not refreshed"
);
assert_eq!(
std::fs::metadata(&fresh).unwrap().modified().unwrap(),
fresh_before,
"a fresh entry costs no write"
);
}
#[test]
fn an_edit_changes_the_cache_key() {
let t = Thumbs {
dir: PathBuf::from("/cache"),
limit: Semaphore::new(1),
ffmpeg: false,
};
let p = Path::new("/data/a.jpg");
assert_ne!(t.entry_path(p, 10, 1), t.entry_path(p, 10, 2));
assert_ne!(t.entry_path(p, 10, 1), t.entry_path(p, 11, 1));
assert_ne!(
t.entry_path(p, 10, 1),
t.entry_path(Path::new("/b.jpg"), 10, 1)
);
assert_eq!(t.entry_path(p, 10, 1), t.entry_path(p, 10, 1));
}
#[test]
fn the_key_fans_out_into_a_bucket() {
let t = Thumbs {
dir: PathBuf::from("/cache"),
limit: Semaphore::new(1),
ffmpeg: false,
};
let p = t.entry_path(Path::new("/data/a.jpg"), 1, 1);
let bucket = p.parent().unwrap().file_name().unwrap().to_str().unwrap();
assert_eq!(bucket.len(), 2);
assert!(bucket.chars().all(|c| c.is_ascii_hexdigit()));
assert!(p.starts_with("/cache"));
}
}
Mserver/tests/api_auth.rs
@@ -204,6 +204,7 @@ async fn root_name_is_configurable() {
root: env.state.root.clone(),
root_name: "Media".to_string(),
https: false,
thumbs: None,
});
env.app = server::api::router(state.clone());
env.state = state;
Aserver/tests/api_thumbs.rs
@@ -0,0 +1,425 @@
//! Thumbnail endpoint: what gets one, what does not, and the cache.
mod common;
use axum::http::StatusCode;
use common::*;
const ROOT: i64 = 1;
fn thumb_path(rel: &str) -> String {
format!("/api/files/{ROOT}/{rel}?action=thumb")
}
/// A small JPEG written into the fixture root. Encoded here rather than
/// checked in as a binary, so the test reads as one piece.
fn write_jpeg(env: &Env, rel: &str, w: u32, h: u32) {
let mut img = image::RgbImage::new(w, h);
// Structure, not a flat colour: a flat image resizes correctly by
// accident.
for (x, y, p) in img.enumerate_pixels_mut() {
*p = image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8]);
}
img.save(env.file(rel)).unwrap();
}
/// Decode a WebP response back to its dimensions.
fn webp_size(bytes: &[u8]) -> (u32, u32) {
let img = image::load_from_memory_with_format(bytes, image::ImageFormat::WebP).unwrap();
(img.width(), img.height())
}
#[tokio::test]
async fn an_image_gets_a_webp_thumbnail() {
let env = Env::with_thumbs().await;
let admin = env.admin().await;
write_jpeg(&env, "photo.jpg", 1200, 800);
let r = admin.get(&thumb_path("photo.jpg")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.headers["content-type"], "image/webp");
// Longest edge capped, aspect ratio kept.
assert_eq!(webp_size(&r.body), (256, 170));
}
#[tokio::test]
async fn an_image_smaller_than_the_cap_is_not_upscaled() {
let env = Env::with_thumbs().await;
let admin = env.admin().await;
write_jpeg(&env, "small.jpg", 80, 40);
let r = admin.get(&thumb_path("small.jpg")).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(webp_size(&r.body), (80, 40));
}
#[tokio::test]
async fn a_transparent_png_is_flattened_onto_white() {
// Dropping the alpha channel instead would leave the RGB behind it, which
// for a cut-out background is black: the tile comes out a black square.
let env = Env::with_thumbs().await;
let admin = env.admin().await;
let mut img = image::RgbaImage::new(64, 64);
for p in img.pixels_mut() {
*p = image::Rgba([0, 0, 0, 0]); // fully transparent, black underneath
}
img.save(env.file("cutout.png")).unwrap();
let r = admin.get(&thumb_path("cutout.png")).await;
assert_eq!(r.status, StatusCode::OK);
let out = image::load_from_memory_with_format(&r.body, image::ImageFormat::WebP)
.unwrap()
.to_rgb8();
let px = out.get_pixel(32, 32).0;
assert!(
px.iter().all(|c| *c > 240),
"expected near-white, got {px:?}"
);
}
#[tokio::test]
async fn the_response_is_immutable() {
// The client varies the URL on the file's mtime, so the bytes behind one
// URL never change. Without that this header would serve stale images.
let env = Env::with_thumbs().await;
let admin = env.admin().await;
write_jpeg(&env, "photo.jpg", 300, 300);
let r = admin.get(&thumb_path("photo.jpg")).await;
let cc = r.headers["cache-control"].to_str().unwrap();
assert!(cc.contains("immutable"), "{cc}");
assert!(cc.contains("private"), "{cc}");
}
#[tokio::test]
async fn the_second_request_is_served_from_the_cache() {
let env = Env::with_thumbs().await;
let admin = env.admin().await;
write_jpeg(&env, "photo.jpg", 600, 400);
let first = admin.get(&thumb_path("photo.jpg")).await;
assert_eq!(first.status, StatusCode::OK);
assert_eq!(webp_size(&first.body), (256, 170));
let cached: Vec<_> = walk_cache(&env);
assert_eq!(cached.len(), 1, "one entry expected: {cached:?}");
// Overwrite the entry with an obviously different image. The second
// answer is that one, so it came off disk and not from a fresh decode.
let marker = image::RgbImage::new(8, 8);
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(marker)
.write_to(&mut buf, image::ImageFormat::WebP)
.unwrap();
std::fs::write(&cached[0], buf.into_inner()).unwrap();
let second = admin.get(&thumb_path("photo.jpg")).await;
assert_eq!(second.status, StatusCode::OK);
assert_eq!(webp_size(&second.body), (8, 8));
assert_eq!(walk_cache(&env).len(), 1, "no second entry made");
}
#[tokio::test]
async fn editing_the_file_makes_a_new_cache_entry() {
let env = Env::with_thumbs().await;
let admin = env.admin().await;
write_jpeg(&env, "photo.jpg", 600, 400);
assert_eq!(
admin.get(&thumb_path("photo.jpg")).await.status,
StatusCode::OK
);
// Different size, so the key changes even if the mtime resolution is
// coarser than the test is fast.
write_jpeg(&env, "photo.jpg", 400, 600);
let r = admin.get(&thumb_path("photo.jpg")).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(
webp_size(&r.body),
(170, 256),
"the new shape, not the old one"
);
assert_eq!(
walk_cache(&env).len(),
2,
"the old entry is left to age out"
);
}
#[tokio::test]
async fn concurrent_requests_for_one_file_all_get_the_thumbnail() {
// Exercises the double-check after the semaphore, where several requests
// generate the same uncached file at once. The narrow write-vs-rename
// race this guards is pinned deterministically by
// `thumb::tests::two_writes_of_one_entry_use_different_scratch_names`.
let env = Env::with_thumbs().await;
let admin = env.admin().await;
write_jpeg(&env, "photo.jpg", 1200, 800);
let mut set = tokio::task::JoinSet::new();
for _ in 0..8 {
let c = admin.clone();
set.spawn(async move { c.get(&thumb_path("photo.jpg")).await });
}
while let Some(r) = set.join_next().await {
let r = r.unwrap();
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(webp_size(&r.body), (256, 170));
}
for f in walk_cache(&env) {
assert!(
std::fs::metadata(&f).unwrap().len() > 0,
"empty entry published: {f:?}"
);
}
}
#[tokio::test]
async fn a_video_without_ffmpeg_caches_nothing() {
// The cache key says nothing about ffmpeg, so a marker written here would
// still be a marker after ffmpeg is installed, and every 404 refreshes
// its mtime so the sweeper never drops it.
if !has_ffmpeg() {
eprintln!("skipped: no ffmpeg to build the fixture");
return;
}
let env = Env::without_ffmpeg().await;
let admin = env.admin().await;
// A real clip, so the server sniffs it as a video. Only the server's view
// of ffmpeg is forced off.
let out = std::process::Command::new("ffmpeg")
.args(["-v", "error", "-y", "-f", "lavfi", "-i"])
.arg("testsrc2=size=160x120:rate=30:duration=1")
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p"])
.arg(env.file("clip.mp4"))
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
admin.get(&thumb_path("clip.mp4")).await.status,
StatusCode::NOT_FOUND
);
assert!(
walk_cache(&env).is_empty(),
"nothing may be cached: {:?}",
walk_cache(&env)
);
}
#[tokio::test]
async fn a_small_video_is_not_upscaled() {
if !has_ffmpeg() {
eprintln!("skipped: no ffmpeg");
return;
}
let env = Env::with_thumbs().await;
let admin = env.admin().await;
let out = std::process::Command::new("ffmpeg")
.args(["-v", "error", "-y", "-f", "lavfi", "-i"])
.arg("testsrc2=size=160x120:rate=30:duration=1")
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p"])
.arg(env.file("small.mp4"))
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let r = admin.get(&thumb_path("small.mp4")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(webp_size(&r.body), (160, 120), "the source size, not 256");
}
#[tokio::test]
async fn a_text_file_has_no_thumbnail() {
let env = Env::with_thumbs().await;
let admin = env.admin().await;
let r = admin.get(&thumb_path("notes.md")).await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_folder_has_no_thumbnail() {
let env = Env::with_thumbs().await;
let admin = env.admin().await;
let r = admin.get(&thumb_path("docs")).await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_broken_image_fails_once_and_is_remembered() {
let env = Env::with_thumbs().await;
let admin = env.admin().await;
// A real JPEG header over garbage: sniffed as an image, refuses to decode.
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
bytes.extend(std::iter::repeat_n(0x7Fu8, 2048));
std::fs::write(env.file("broken.jpg"), &bytes).unwrap();
assert_eq!(
admin.get(&thumb_path("broken.jpg")).await.status,
StatusCode::NOT_FOUND
);
// The empty marker file: the next visit to this folder does not decode
// it again.
let cached = walk_cache(&env);
assert_eq!(cached.len(), 1, "{cached:?}");
assert_eq!(std::fs::metadata(&cached[0]).unwrap().len(), 0);
assert_eq!(
admin.get(&thumb_path("broken.jpg")).await.status,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn thumbnails_are_off_without_a_cache_folder() {
let env = Env::new().await;
let admin = env.admin().await;
write_jpeg(&env, "photo.jpg", 300, 300);
assert_eq!(
admin.get(&thumb_path("photo.jpg")).await.status,
StatusCode::NOT_FOUND
);
let me = admin.get("/api/auth/me").await.json();
assert_eq!(me["thumbnails_available"], false);
}
#[tokio::test]
async fn the_profile_setting_round_trips() {
let env = Env::with_thumbs().await;
let admin = env.admin().await;
let me = admin.get("/api/auth/me").await.json();
assert_eq!(me["thumbnails_available"], true);
assert_eq!(me["user"]["thumbnails"], true, "on by default");
let r = admin
.put_json("/api/auth/me", &serde_json::json!({ "thumbnails": false }))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["user"]["thumbnails"], false);
// Still set on the next read, not only in the reply.
let me = admin.get("/api/auth/me").await.json();
assert_eq!(me["user"]["thumbnails"], false);
// A user preference, not an access rule: the endpoint still answers.
write_jpeg(&env, "photo.jpg", 300, 300);
assert_eq!(
admin.get(&thumb_path("photo.jpg")).await.status,
StatusCode::OK
);
}
#[tokio::test]
async fn another_users_root_is_still_out_of_reach() {
let env = Env::with_thumbs().await;
let admin = env.admin().await;
write_jpeg(&env, "photo.jpg", 300, 300);
create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await;
let bob = login(&env, "bob", "bobpass123").await;
// Root 1 is the admin's whole-root folder, which Bob does not have.
let r = bob.get("/api/files/1/photo.jpg?action=thumb").await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
// Root 2 is Bob's `docs`. The file sits above it, so `..` must not reach
// out of the root even though the root itself is his.
let r = bob.get("/api/files/2/../photo.jpg?action=thumb").await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
}
#[tokio::test]
async fn a_share_scopes_thumbnails_to_the_shared_folder() {
let env = Env::with_thumbs().await;
let admin = env.admin().await;
write_jpeg(&env, "docs/inside.jpg", 300, 200);
write_jpeg(&env, "outside.jpg", 300, 200);
let s = admin
.post_json(
"/api/shares",
&serde_json::json!({ "root_id": 1, "path": "docs", "writable": false }),
)
.await
.json();
let token = s["token"].as_str().unwrap();
let root_id = s["root_id"].as_i64().unwrap();
let anon = Client::new(env.app.clone());
// A file in the shared folder gets a thumbnail without signing in.
let r = anon
.get(&format!(
"/api/files/{root_id}/inside.jpg?share={token}&action=thumb"
))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.headers["content-type"], "image/webp");
// A file outside it does not, and no thumbnail of it reaches the cache.
let r = anon
.get(&format!(
"/api/files/{root_id}/../outside.jpg?share={token}&action=thumb"
))
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
assert_eq!(walk_cache(&env).len(), 1);
}
/// Whether ffmpeg is on this machine. Video thumbnails need it and the
/// server treats it as optional, so the test does too.
fn has_ffmpeg() -> bool {
std::process::Command::new("ffmpeg")
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[tokio::test]
async fn a_video_gets_a_thumbnail_of_one_frame() {
if !has_ffmpeg() {
eprintln!("skipped: no ffmpeg");
return;
}
let env = Env::with_thumbs().await;
let admin = env.admin().await;
// Three seconds of colour bars, long enough to survive the seek.
let out = std::process::Command::new("ffmpeg")
.args(["-v", "error", "-y", "-f", "lavfi", "-i"])
.arg("testsrc2=size=640x360:rate=30:duration=3")
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p"])
.arg(env.file("clip.mp4"))
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let r = admin.get(&thumb_path("clip.mp4")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.headers["content-type"], "image/webp");
assert_eq!(webp_size(&r.body), (256, 144));
}
/// Every file in the cache folder, buckets included.
fn walk_cache(env: &Env) -> Vec<std::path::PathBuf> {
let dir = env.cache.as_ref().expect("cache env").path();
let mut out = Vec::new();
for bucket in std::fs::read_dir(dir).unwrap().flatten() {
if bucket.path().is_dir() {
for f in std::fs::read_dir(bucket.path()).unwrap().flatten() {
out.push(f.path());
}
}
}
out.sort();
out
}
Mserver/tests/common/mod.rs
@@ -39,10 +39,27 @@ pub struct Env {
pub root: tempfile::TempDir,
pub state: Arc<AppState>,
pub app: Router,
/// Kept alive so the thumbnail cache folder outlives the server.
pub cache: Option<tempfile::TempDir>,
}
impl Env {
pub async fn new() -> Self {
Self::build(false, true).await
}
/// Same fixtures, plus a thumbnail cache in a temp folder.
pub async fn with_thumbs() -> Self {
Self::build(true, true).await
}
/// A thumbnail cache with ffmpeg reported as missing, so the no-video
/// branch can be exercised on a machine that has it.
pub async fn without_ffmpeg() -> Self {
Self::build(true, false).await
}
async fn build(thumbs: bool, ffmpeg: bool) -> Self {
let root = tempfile::tempdir().unwrap();
let p = root.path();
std::fs::create_dir_all(p.join("docs/inner")).unwrap();
@@ -57,14 +74,32 @@ impl Env {
// In-memory SQLite: no temp files, no WAL, faster than file-backed.
let db = Db::open_in_memory().await.unwrap();
let cache = match thumbs {
true => Some(tempfile::tempdir().unwrap()),
false => None,
};
let thumbs = match &cache {
Some(d) => Some(Arc::new(
server::thumb::Thumbs::with_ffmpeg(d.path().to_path_buf(), ffmpeg)
.await
.unwrap(),
)),
None => None,
};
let state = Arc::new(AppState {
db,
root: p.canonicalize().unwrap(),
root_name: server::root_file_name(p),
https: false,
thumbs,
});
let app = server::api::router(state.clone());
Self { root, state, app }
Self {
root,
state,
app,
cache,
}
}
/// Absolute path of a fixture file inside the root.
@@ -93,6 +128,7 @@ impl Env {
// Client
// ---------------------------------------------------------------------------
#[derive(Clone)]
pub struct Client {
app: Router,
pub cookie: Option<String>,
Mweb/app.css
@@ -700,6 +700,36 @@ button:disabled {
display: block;
}
/* Holds the icon with the thumbnail layered over it. The icon is the
fallback when no thumbnail loads. */
.tile-media {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
.tile-media .tile-icon {
margin: 0;
}
/* One tile height per folder: the taller media area applies to every tile,
not only the ones that end up with a thumbnail. */
.has-thumbs .tile-media {
height: 92px;
}
/* No background: the element must stay transparent until the bytes arrive,
or it hides the icon underneath as a dark rectangle. */
.tile-thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.tile-name {
font-size: 13px;
line-height: 1.3;
Mweb/src/api.rs
@@ -14,10 +14,10 @@ use wasm_bindgen_futures::JsFuture;
use api_types::{
ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_MKDIR, ACTION_PREVIEW,
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare,
CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH, P_Q, P_ROOT,
P_SCOPE, P_SHARE, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, Settings, UnlockShare,
UpdateUser,
ACTION_THUMB, ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP,
CreateShare, CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH,
P_Q, P_ROOT, P_SCOPE, P_SHARE, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, Settings,
UnlockShare, UpdateUser,
};
pub use api_types::{
AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo, UserInfo,
@@ -98,11 +98,14 @@ struct ProfilePatch {
#[serde(skip_serializing_if = "Option::is_none")]
single_click_open: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
thumbnails: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
language: Option<Option<String>>,
}
pub fn update_profile(
single_click_open: Option<bool>,
thumbnails: Option<bool>,
language: Option<Option<String>>,
) -> impl std::future::Future<Output = Result<Me, ApiError>> {
request(
@@ -110,6 +113,7 @@ pub fn update_profile(
AUTH_ME.to_string(),
Some(ProfilePatch {
single_click_open,
thumbnails,
language,
}),
)
@@ -310,6 +314,20 @@ pub fn preview_url(root_id: i64, path: &str) -> String {
)
}
/// `...?action=thumb` — a small WebP for the grid.
///
/// `mtime` is in the query so a changed file is a new URL. The server marks
/// the response immutable, which depends on that.
pub fn thumb_url(root_id: i64, path: &str, mtime: &str) -> String {
append_query(
&files_url(root_id, path),
&format!(
"{P_ACTION}={ACTION_THUMB}&v={}",
js_sys::encode_uri_component(mtime)
),
)
}
/// `...?action=content` — raw file bytes for the text preview/editor.
pub fn content_url(root_id: i64, path: &str) -> String {
append_query(
Mweb/src/i18n.rs
@@ -466,6 +466,8 @@ i18n_keys! {
THEME_DARK = "theme_dark" => "Dark",
THEME_LIGHT = "theme_light" => "Light",
THEME_TITLE = "theme_title" => "Theme: {} (click to switch)",
THUMBNAILS_DESC = "thumbnails_desc" => "Show a small preview instead of an icon for images and videos in grid view.",
THUMBNAILS_LABEL = "thumbnails_label" => "Show thumbnails",
TYPE = "type" => "Type",
UNLOCK = "unlock" => "Open share",
UNSAVED_CHANGES = "unsaved_changes" => "Unsaved changes",
@@ -950,6 +952,11 @@ const DE: &[(&str, &str)] = &[
("theme_dark", "Dunkel"),
("theme_light", "Hell"),
("theme_title", "Design: {} (klicken zum Wechseln)"),
(
"thumbnails_desc",
"Für Bilder und Videos in der Kachelansicht eine kleine Vorschau statt eines Symbols zeigen.",
),
("thumbnails_label", "Vorschaubilder zeigen"),
("type", "Typ"),
("unlock", "Freigabe öffnen"),
("unsaved_changes", "Ungespeicherte Änderungen"),
@@ -1434,6 +1441,11 @@ const FR: &[(&str, &str)] = &[
("theme_dark", "Sombre"),
("theme_light", "Clair"),
("theme_title", "Thème : {} (cliquer pour changer)"),
(
"thumbnails_desc",
"Afficher un aperçu à la place d'une icône pour les images et les vidéos en vue grille.",
),
("thumbnails_label", "Afficher les miniatures"),
("type", "Type"),
("unlock", "Ouvrir le partage"),
("unsaved_changes", "Modifications non enregistrées"),
Mweb/src/views/admin.rs
@@ -118,9 +118,14 @@ pub fn SettingsView(
let (profile_busy, set_profile_busy) = signal(false);
// Profile language: Some(None) = auto (follow the browser).
let (language, set_language) = signal(Option::<Option<String>>::None);
let (thumbnails, set_thumbnails) = signal(Option::<bool>::None);
// Without `--cache` the server cannot make thumbnails, so hide the row
// rather than offer a setting that does nothing.
let thumbs_available = move || me.get().is_some_and(|m| m.thumbnails_available);
Effect::new(move |_| {
if let Some(u) = me.get().and_then(|m| m.user) {
set_single_click.set(Some(u.single_click_open));
set_thumbnails.set(Some(u.thumbnails));
set_language.set(Some(u.language.clone()));
}
});
@@ -130,20 +135,23 @@ pub fn SettingsView(
return;
}
let Some(v) = single_click.get() else { return };
let Some(th) = thumbnails.get() else { return };
let Some(lang) = language.get() else { return };
set_profile_busy.set(true);
let toast2 = toast;
let set_me2 = set_me;
let set_val = set_single_click;
let set_th = set_thumbnails;
let set_lang = set_language;
spawn_local(async move {
match api::update_profile(Some(v), Some(lang.clone())).await {
match api::update_profile(Some(v), Some(th), Some(lang.clone())).await {
Ok(m) => {
show(toast2, i18n::t(i18n::k::PROFILE_SAVED).to_string());
// The response already carries the fresh /me (which also
// flips the UI language via the app-level effect).
set_me2.set(Some(m));
set_val.set(Some(v));
set_th.set(Some(th));
set_lang.set(Some(lang));
}
Err(e) => show(
@@ -279,6 +287,26 @@ pub fn SettingsView(
}
/>
</label>
<Show when=thumbs_available>
<label class="setting-row">
<span>
<span class="setting-label">{i18n::t(i18n::k::THUMBNAILS_LABEL)}</span>
<span class="setting-desc">{i18n::t(i18n::k::THUMBNAILS_DESC)}</span>
</span>
<input
type="checkbox"
checked=move || thumbnails.get().unwrap_or(false)
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_thumbnails.set(Some(t.checked()));
}
}
/>
</label>
</Show>
<div class="setting-row setting-row-select">
<span>
<span class="setting-label">{i18n::t(i18n::k::LANGUAGE)}</span>
Mweb/src/views/browser.rs
@@ -401,6 +401,9 @@ fn file_browser(
.get()
.and_then(|m| m.user)
.is_some_and(|u| u.single_click_open);
let thumbs = me
.get()
.is_some_and(|m| m.thumbnails_available && m.user.is_some_and(|u| u.thumbnails));
// Breadcrumbs
let crumbs = view! {
@@ -602,6 +605,7 @@ fn file_browser(
loc,
is_rw,
single_click,
thumbs,
selected,
sel_names,
set_selected,
@@ -977,6 +981,7 @@ fn entries_view(
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
thumbs: bool,
selected: ReadSignal<Vec<Entry>>,
sel_names: Memo<HashSet<String>>,
set_selected: WriteSignal<Vec<Entry>>,
@@ -990,6 +995,16 @@ fn entries_view(
} else {
("entries-list", "row", "row-icon")
};
// Grid only: a list row is 28px of icon, too small to be worth a request
// per entry.
let thumbs = thumbs && grid;
let container = match thumbs {
true => format!("{container} has-thumbs"),
false => container.to_string(),
};
// Untracked: a change to the location refetches the listing, which
// re-runs this anyway.
let dir = loc.get_untracked().path;
view! {
<div class=container>
// Only the visible page is rendered; the click handlers still
@@ -1010,6 +1025,10 @@ fn entries_view(
all.clone(), i, root_id, loc, is_rw, single_click,
selected, set_selected, set_ctx, open_file,
);
let thumb_src = (thumbs
&& !e.is_dir
&& matches!(e.kind, FileKind::Image | FileKind::Video))
.then(|| api::thumb_url(root_id, &join_path(&dir, &e.name), &e.mtime));
let all_keys = all.clone();
let item_name = name.clone();
let selected_now = move || sel_names.with(|s| s.contains(&item_name));
@@ -1031,13 +1050,31 @@ fn entries_view(
on:contextmenu=on_ctx
title=title
>
// `icon_svg`, not `<Icon/>`: no reactive owner
// per item (see its doc).
{icon_svg(icon, icon_class)}
{if grid {
view! { <div class="tile-name">{name}</div> }.into_any()
view! {
<div class="tile-media">
// `icon_svg`, not `<Icon/>`: no
// reactive owner per item (see its
// doc).
{icon_svg(icon, icon_class)}
// An empty `alt` means a 404 renders
// as nothing, leaving the icon. No
// error handler is needed.
{thumb_src.map(|src| view! {
<img
class="tile-thumb"
src=src
alt=""
loading="lazy"
decoding="async"
/>
})}
</div>
<div class="tile-name">{name}</div>
}.into_any()
} else {
view! {
{icon_svg(icon, icon_class)}
<span class="row-name">{name}</span>
<span class="row-size muted">{size}</span>
<span class="row-date muted">{date}</span>