keyboard navigation, sorting, share passwords, search excludes

Arrow keys move the selection in the browser: all four in grid view, up
and down in list view, Shift extends from the anchor. Left and right step
between previewable files while a preview is open. A sort control orders
by name, size or last modified, either direction, folders always first.

Shares take an optional password. The gate is the AuthUser extractor, not
just the resolve endpoint, so the file API is covered too. Unlocking sets
a per-share HttpOnly cookie, because previews and downloads are plain
URLs in src and href attributes, which carry nothing else. The unlock
route reuses the login route's per-attempt delay.

A new admin setting lists folders no search may enter. Excluded folders
are skipped whole during the walk, so nothing under them is read.

The editor's unsaved-changes and conflict dialogs used a class with no
CSS anywhere, left behind when the app moved to a native dialog. They
render through the Modal component now, which brings the backdrop, the
focus trap and Escape.

Also: .center-screen asked for a full 100vh below the topbar and pushed
a scrollbar onto every page that used it; .btn-primary forced 100% width
on every primary button in the app.
AuthorKonata <konata@posteo.jp>
Date
Commit73aac04ccaec18a9e7c7037182b4181c9aaff74a
Parentc268712
21 files changed, 1819 insertions(+), 143 deletions(-)
▾Mapi-types/src/lib.rs
@@ -20,6 +20,8 @@ pub const FILES: &str = "/api/files";
pub const SHARES: &str = "/api/shares";
/// Public share resolve (no login): `{SHARE}/{token}`.
pub const SHARE: &str = "/api/share";
/// Suffix on `{SHARE}/{token}`: submit the password of a protected share.
pub const SHARE_UNLOCK_SUFFIX: &str = "/unlock";
/// `GET /api/search` — name and/or content search, streamed as SSE.
pub const SEARCH: &str = "/api/search";
@@ -172,9 +174,13 @@ pub struct UpdateUser {
}
/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
#[derive(Serialize, Deserialize, Clone, Copy)]
#[derive(Serialize, Deserialize, Clone)]
pub struct Settings {
pub allow_writable_shares: bool,
/// Folders left out of every search, as paths relative to the server
/// root. A path covers everything beneath it.
#[serde(default)]
pub search_excludes: Vec<String>,
}
#[derive(Serialize, Deserialize)]
@@ -187,6 +193,15 @@ pub struct CreateShare {
/// Absolute expiry as RFC 3339; absent = never.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
/// Password the visitor must enter before the share opens; absent = none.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
}
/// POST `{SHARE}/{token}/unlock` — the password for a protected share.
#[derive(Serialize, Deserialize)]
pub struct UnlockShare {
pub password: String,
}
// ---------------------------------------------------------------------------
@@ -329,6 +344,9 @@ pub struct ShareInfo {
/// The file's kind for file shares (None for folder shares, and when
/// not sniffed — the public resolve endpoint fills it in).
pub kind: Option<FileKind>,
/// Whether the share asks for a password. Never the password itself.
#[serde(default)]
pub has_password: bool,
}
/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
▾Mserver/src/api/admin.rs
@@ -244,6 +244,7 @@ pub async fn get_settings(
) -> Result<Json<Settings>, ApiError> {
Ok(Json(Settings {
allow_writable_shares: state.db.allow_writable_shares().await?,
search_excludes: state.db.search_excludes().await?,
}))
}
@@ -257,5 +258,20 @@ pub async fn update_settings(
.db
.set_allow_writable_shares(body.allow_writable_shares)
.await?;
Ok(Json(body))
// Normalised so the search can compare plain strings. "." is dropped:
// excluding the root would switch search off instead of narrowing it.
let mut excludes: Vec<String> = Vec::new();
for p in &body.search_excludes {
let p = p.trim().replace('\\', "/");
let p = p.trim_matches('/');
if p.is_empty() || p == "." || excludes.iter().any(|e| e == p) {
continue;
}
excludes.push(p.to_string());
}
state.db.set_search_excludes(&excludes).await?;
Ok(Json(Settings {
allow_writable_shares: body.allow_writable_shares,
search_excludes: excludes,
}))
}
▾Mserver/src/api/common.rs
@@ -40,6 +40,12 @@ where
if let Some(share_token) = share_token_from_request(parts) {
return match state.db.share_by_token(&share_token).await? {
Some(share) if !share.is_expired() => {
// The password guards the files, not just the share
// page. A check only on resolve would leave the file
// API open to anyone holding the link.
if share_is_locked(state, &share, &parts.headers).await? {
return Err(crate::api::shares::locked_error());
}
let roots = vec![RootRow {
id: share.id,
path: share.target.clone(),
@@ -132,6 +138,21 @@ pub(crate) async fn session_auth(
Ok((user, roots))
}
/// Whether `share` still needs its password entered by this caller.
pub(crate) async fn share_is_locked(
state: &AppState,
share: &ShareRow,
headers: &axum::http::HeaderMap,
) -> Result<bool, ApiError> {
if share.password_hash.is_none() {
return Ok(false);
}
let Some(unlock) = crate::auth::parse_share_cookie(headers, share.id) else {
return Ok(true);
};
Ok(!state.db.share_unlock_valid(&unlock, share.id).await?)
}
/// Extract the share token from a request, if present: a `?share=<token>`
/// query param or an `X-Share-Token` header.
fn share_token_from_request(parts: &Parts) -> Option<String> {
▾Mserver/src/api/mod.rs
@@ -2,7 +2,7 @@ use std::sync::Arc;
use api_types::{
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, FILES, SEARCH,
SHARE, SHARES,
SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -79,6 +79,7 @@ pub fn router(state: Arc<AppState>) -> Router {
let files_item = format!("{FILES}/{{root_id}}/{{*path}}");
let shares_id = format!("{SHARES}/{{id}}");
let share_token = format!("{SHARE}/{{token}}");
let share_unlock = format!("{SHARE}/{{token}}{SHARE_UNLOCK_SUFFIX}");
let admin_user_id = format!("{ADMIN_USERS}/{{id}}");
Router::new()
@@ -97,6 +98,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(SHARES, post(shares::create))
.route(&shares_id, delete(shares::delete))
.route(&share_token, get(shares::resolve))
.route(&share_unlock, post(shares::unlock))
.route(ADMIN_USERS, get(admin::list_users))
.route(ADMIN_USERS, post(admin::create_user))
.route(&admin_user_id, put(admin::update_user))
▾Mserver/src/api/search.rs
@@ -170,6 +170,7 @@ pub(super) async fn search(
root,
start_rel,
state.root.clone(),
state.db.search_excludes().await?,
slot,
);
// `Sse` does the `data: <json>\n\n` framing and the content-type and
@@ -188,6 +189,9 @@ struct SearchState {
/// Where the walk starts, relative to the root ("" = the root itself).
start_rel: String,
server_root: PathBuf,
/// Admin-configured folders left out of every search, relative to the
/// server root and already normalised (no slashes at either end).
excludes: Vec<String>,
started: Instant,
/// Directory entries visited.
scanned: AtomicUsize,
@@ -205,6 +209,7 @@ struct SearchState {
/// Stopping: when the client goes away, axum drops the body stream, which
/// drops the receiver. Every `is_closed` check in the walkers then yields
/// `WalkState::Quit` at the next entry, so the walk unwinds promptly.
#[allow(clippy::too_many_arguments)] // one search's whole configuration
fn search_stream(
q: String,
want_name: bool,
@@ -212,6 +217,7 @@ fn search_stream(
root: RootRow,
start_rel: String,
server_root: PathBuf,
excludes: Vec<String>,
slot: tokio::sync::OwnedSemaphorePermit,
) -> impl futures_util::Stream<Item = SearchEvent> + Send {
let (tx, rx) = tokio::sync::mpsc::channel::<SearchEvent>(256);
@@ -221,6 +227,7 @@ fn search_stream(
root,
start_rel,
server_root,
excludes,
started: Instant::now(),
scanned: AtomicUsize::new(0),
skipped: AtomicUsize::new(0),
@@ -249,6 +256,28 @@ fn search_stream(
tokio_stream::wrappers::ReceiverStream::new(rx)
}
/// A root-relative path re-expressed relative to the server root, the form
/// the exclude list is stored in. `root_path` is "." for the whole root.
fn join_rel(root_path: &str, rel: &str) -> String {
let root_path = root_path.trim_matches('/');
if root_path.is_empty() || root_path == "." {
rel.to_string()
} else {
format!("{root_path}/{rel}")
}
}
/// Whether `path` is an excluded folder or sits under one.
///
/// `Path::starts_with` compares whole components, so "docs" does not exclude
/// the sibling "docs-archive". A plain string prefix would.
fn is_excluded(excludes: &[String], path: &str) -> bool {
let path = Path::new(path);
excludes
.iter()
.any(|e| crate::fs::is_within_or_eq(Path::new(e), path))
}
/// True when every query word occurs in `name`. Both are already lowercased.
///
/// `name` is the entry's own name, never its path — see the module docs.
@@ -308,6 +337,19 @@ fn visit(
let rel = rel.to_string_lossy().replace('\\', "/");
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
// `Skip` on the folder itself stops the walker descending, so nothing
// underneath is ever read.
if !st.excludes.is_empty() {
let from_server_root = join_rel(&st.root.path, &rel);
if is_excluded(&st.excludes, &from_server_root) {
return if is_dir {
WalkState::Skip
} else {
WalkState::Continue
};
}
}
if want_name {
// Matched against the entry's own name, not its path: matching the
// path makes every descendant of a matching directory a hit too
@@ -437,6 +479,33 @@ fn truncate_line(line: &[u8]) -> String {
mod tests {
use super::*;
/// A sibling whose name merely starts with an excluded folder's name
/// must still be searchable.
#[test]
fn excludes_cover_descendants_but_not_name_siblings() {
let ex = vec!["private".to_string(), "a/b".to_string()];
assert!(is_excluded(&ex, "private"));
assert!(is_excluded(&ex, "private/deep/file.txt"));
assert!(is_excluded(&ex, "a/b"));
assert!(is_excluded(&ex, "a/b/c.txt"));
assert!(!is_excluded(&ex, "private-archive"));
assert!(!is_excluded(&ex, "privateer.txt"));
assert!(!is_excluded(&ex, "a"));
assert!(!is_excluded(&ex, "a/bc"));
assert!(!is_excluded(&ex, "other/private"));
assert!(!is_excluded(&[], "anything"));
}
/// Results are root-relative; the exclude list is server-root-relative.
#[test]
fn join_rel_lifts_a_path_to_the_server_root() {
assert_eq!(join_rel(".", "docs/a.txt"), "docs/a.txt");
assert_eq!(join_rel("", "docs/a.txt"), "docs/a.txt");
assert_eq!(join_rel("home/bob", "docs/a.txt"), "home/bob/docs/a.txt");
assert_eq!(join_rel("/home/bob/", "x"), "home/bob/x");
}
/// A rename of one of the `P_*` constants without the matching field
/// rename would silently stop the server from reading the parameter the
/// client sends. This builds the query string from the constants and
▾Mserver/src/api/shares.rs
@@ -11,12 +11,17 @@
use std::sync::Arc;
use api_types::{CreateShare, Mode, OkResp, ShareInfo};
use api_types::{CreateShare, Mode, OkResp, ShareInfo, UnlockShare};
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use axum::http::header::{HeaderMap, SET_COOKIE};
use axum::response::{IntoResponse, Response};
use crate::api::common::{SessionUser, blocking, display_name, target_rel};
use crate::api::common::{
SessionUser, blocking, display_name, hash_password, share_is_locked, target_rel,
validate_password,
};
use crate::auth;
use crate::db::ShareRow;
use crate::error::{ApiError, AppState};
@@ -36,6 +41,7 @@ fn share_info(row: &ShareRow, state: &AppState) -> ShareInfo {
// The synthetic root id to use in file API calls.
root_id: row.id,
kind: None,
has_password: row.password_hash.is_some(),
}
}
@@ -74,6 +80,16 @@ pub async fn create(
));
}
// Validated and hashed before the row is written, so a rejected
// password cannot leave a half-made share behind.
let password_hash = match body.password.as_deref().map(str::trim) {
Some(pw) if !pw.is_empty() => {
validate_password(pw)?;
Some(hash_password(pw).await?)
}
_ => None,
};
let root = auth
.roots
.iter()
@@ -118,6 +134,7 @@ pub async fn create(
is_file,
mode,
body.expires_at.as_deref(),
password_hash.as_deref(),
)
.await?;
@@ -143,6 +160,7 @@ pub async fn delete(
/// GET /api/share/{token} — public resolve for the share page.
pub async fn resolve(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
AxumPath(token): AxumPath<String>,
) -> Result<Json<ShareInfo>, ApiError> {
let Some(row) = state.db.share_by_token(&token).await? else {
@@ -159,9 +177,24 @@ pub async fn resolve(
"err_share_expired",
));
}
let mut info = share_info(&row, &state);
// A file share opens straight into the viewer, so the client needs the
// file's kind up front (it cannot list a file's "contents").
// Nothing is returned before the password. The shared item's name is
// itself information.
if share_is_locked(&state, &row, &headers).await? {
return Err(locked_error());
}
Ok(Json(share_info_sniffed(&row, &state).await))
}
/// [`share_info`] plus the file's kind for a file share.
///
/// A file share opens straight into the viewer, so the client needs the kind
/// up front. It cannot list a file's "contents" to find out.
///
/// Both the resolve and the unlock endpoint answer with this. A visitor who
/// unlocks a protected share never calls resolve again, so a bare
/// `share_info` there left the viewer with nothing to open.
async fn share_info_sniffed(row: &ShareRow, state: &AppState) -> ShareInfo {
let mut info = share_info(row, state);
if row.is_file {
let (server_root, target) = (state.root.clone(), row.target.clone());
// An unresolvable target just means no kind; the share itself is
@@ -171,5 +204,84 @@ pub async fn resolve(
.ok()
.map(|p| fs::detect_kind(&p, false));
}
Ok(Json(info))
info
}
/// The 401 that tells the client to ask for the share's password.
///
/// The share page branches on the code, so a locked share must stay
/// distinguishable from a missing one.
pub(crate) fn locked_error() -> ApiError {
ApiError::localized(
StatusCode::UNAUTHORIZED,
"this share is password protected",
"err_share_locked",
)
}
/// POST /api/share/{token}/unlock — submit a protected share's password.
///
/// On success the visitor gets a per-share session cookie. A cookie, not a
/// header: previews and downloads are plain URLs in `src` and `href`
/// attributes, which carry cookies and nothing else.
pub async fn unlock(
State(state): State<Arc<AppState>>,
AxumPath(token): AxumPath<String>,
Json(body): Json<UnlockShare>,
) -> Result<Response, ApiError> {
let Some(row) = state.db.share_by_token(&token).await? else {
return Err(ApiError::localized(
StatusCode::NOT_FOUND,
"share not found",
"err_share_not_found",
));
};
if row.is_expired() {
return Err(ApiError::localized(
StatusCode::GONE,
"this share has expired",
"err_share_expired",
));
}
let Some(hash) = row.password_hash.clone() else {
// Nothing to verify. Answering "ok" would mint a cookie that no
// later request ever checks.
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"this share has no password",
"err_share_no_password",
));
};
// Same throttle as the login route, keyed by the share token. The token
// is 128 bits, but the password is the weak half and the attacker
// already holds the token. Without this, guessing runs at full speed and
// a flood of attempts also drains the shared Argon2 permits that real
// logins need.
let delay = auth::login_delay(&token);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
let pw = body.password;
let _slot = auth::ARGON2_SLOTS.acquire().await;
let ok = tokio::task::spawn_blocking(move || auth::verify_password(&pw, &hash))
.await
.map_err(|_| crate::api::common::internal_error())?;
auth::record_login(&token, ok);
if !ok {
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
"wrong password",
"err_share_wrong_password",
));
}
let unlock = state.db.create_share_unlock(row.id).await?;
let cookie = auth::share_cookie(row.id, &unlock, state.https);
Ok((
[(SET_COOKIE, cookie)],
Json(share_info_sniffed(&row, &state).await),
)
.into_response())
}
▾Mserver/src/auth.rs
@@ -103,13 +103,46 @@ pub fn clear_session_cookie(https: bool) -> String {
c
}
/// Cookie name proving that the visitor unlocked share `share_id`.
///
/// One cookie per share: a visitor may hold links to several protected
/// shares, and one shared name would let each unlock evict the last.
pub fn share_cookie_name(share_id: i64) -> String {
format!("fbng_share_{share_id}")
}
/// Session cookie for an unlocked share. A session cookie (no `Max-Age`), so
/// the unlock lasts as long as the browser stays open and is not written to
/// disk.
pub fn share_cookie(share_id: i64, token: &str, https: bool) -> String {
let mut c = format!(
"{}={token}; Path=/; HttpOnly; SameSite=Lax",
share_cookie_name(share_id)
);
if https {
c.push_str("; Secure");
}
c
}
/// Extract the unlock token for `share_id` from the Cookie header.
pub fn parse_share_cookie(headers: &axum::http::HeaderMap, share_id: i64) -> Option<String> {
cookie_value(headers, &share_cookie_name(share_id))
}
/// Extract the session token from the Cookie header, if present.
pub fn parse_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
cookie_value(headers, COOKIE_NAME)
}
/// One cookie's value out of the `Cookie` header. Empty values are treated
/// as absent: that is how a cleared cookie arrives before it expires.
fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
for part in header.split(';') {
let part = part.trim();
if let Some((k, v)) = part.split_once('=')
&& k == COOKIE_NAME
&& k == name
&& !v.is_empty()
{
return Some(v.to_string());
@@ -123,6 +156,26 @@ mod tests {
use super::*;
use axum::http::{HeaderMap, header};
#[test]
fn share_cookie_is_per_share_and_session_scoped() {
let c = share_cookie(7, "tok", false);
assert!(c.starts_with("fbng_share_7=tok;"));
assert!(c.contains("HttpOnly"));
// No Max-Age: the unlock must not outlive the browser session.
assert!(!c.contains("Max-Age"));
assert!(!c.contains("Secure"));
assert!(share_cookie(7, "tok", true).contains("Secure"));
let mut h = HeaderMap::new();
h.insert(
header::COOKIE,
"fbng_share_7=abc; fbng_share_8=def".parse().unwrap(),
);
assert_eq!(parse_share_cookie(&h, 7).as_deref(), Some("abc"));
assert_eq!(parse_share_cookie(&h, 8).as_deref(), Some("def"));
assert_eq!(parse_share_cookie(&h, 9), None);
}
#[test]
fn password_hash_round_trip() {
let h = hash_password("hunter22").unwrap();
▾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 = 5;
const SCHEMA_VERSION: i64 = 7;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
@@ -61,6 +61,10 @@ pub struct ShareRow {
pub mode: Mode,
pub created_at: String,
pub expires_at: Option<String>,
/// Argon2 hash of the share's password, when it has one. Resolve,
/// listing and download all stay locked until the visitor enters it and
/// gets an unlock cookie.
pub password_hash: Option<String>,
}
impl ShareRow {
@@ -159,6 +163,26 @@ impl Db {
"CREATE INDEX IF NOT EXISTS idx_shares_creator ON shares(creator_id)",
)?;
}
if version < 6 {
// Unlocks cascade with their share, which cascades with its
// creator's account.
conn.execute_batch(
"ALTER TABLE shares ADD COLUMN password_hash TEXT;
CREATE TABLE IF NOT EXISTS share_unlocks (
token TEXT PRIMARY KEY,
share_id INTEGER NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);",
)?;
}
if version < 7 {
// `delete_share` cascades into share_unlocks, which is a full
// scan of that table without this.
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_share_unlocks_share
ON share_unlocks(share_id)",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -493,6 +517,7 @@ impl Db {
// ---------- shares ----------
#[allow(clippy::too_many_arguments)] // one row's columns, all required
pub async fn create_share(
&self,
creator_id: i64,
@@ -501,11 +526,13 @@ impl Db {
is_file: bool,
mode: Mode,
expires_at: Option<&str>,
password_hash: Option<&str>,
) -> DbResult<ShareRow> {
let c = self.0.lock().await;
c.execute(
"INSERT INTO shares (token, creator_id, target, is_file, mode, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
"INSERT INTO shares
(token, creator_id, target, is_file, mode, created_at, expires_at, password_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
token,
creator_id,
@@ -513,7 +540,8 @@ impl Db {
is_file as i64,
SqlMode(mode),
now(),
expires_at
expires_at,
password_hash
],
)?;
let id = c.last_insert_rowid();
@@ -526,12 +554,47 @@ impl Db {
mode,
created_at: now(),
expires_at: expires_at.map(|s| s.to_string()),
password_hash: password_hash.map(|s| s.to_string()),
})
}
/// Record that a visitor entered `share_id`'s password, and return the
/// token that proves it (the value of their unlock cookie).
pub async fn create_share_unlock(&self, share_id: i64) -> DbResult<String> {
let token = crate::auth::random_token();
let c = self.0.lock().await;
// Old unlocks go first. The cookie carrying them is a session
// cookie, so it is already gone from every browser; without this the
// rows would accumulate forever, one per unlock.
c.execute(
"DELETE FROM share_unlocks WHERE created_at < ?1",
[expiry_cutoff()],
)?;
c.execute(
"INSERT INTO share_unlocks (token, share_id, created_at) VALUES (?1, ?2, ?3)",
params![token, share_id, now()],
)?;
Ok(token)
}
/// Whether `token` is a live unlock for `share_id`.
///
/// The share id is part of the lookup, so an unlock for one share cannot
/// open another.
pub async fn share_unlock_valid(&self, token: &str, share_id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
let mut stmt =
c.prepare_cached("SELECT 1 FROM share_unlocks WHERE token = ?1 AND share_id = ?2")?;
Ok(stmt
.query_row(params![token, share_id], |_| Ok(()))
.optional()?
.is_some())
}
pub async fn share_by_token(&self, token: &str) -> DbResult<Option<ShareRow>> {
let c = self.0.lock().await;
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at,
password_hash
FROM shares WHERE token = ?1";
let mut stmt = c.prepare_cached(sql)?;
stmt.query_row([token], map_share).optional()
@@ -539,7 +602,8 @@ impl Db {
pub async fn user_shares(&self, creator_id: i64) -> DbResult<Vec<ShareRow>> {
let c = self.0.lock().await;
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at,
password_hash
FROM shares WHERE creator_id = ?1 ORDER BY id DESC";
let mut stmt = c.prepare_cached(sql)?;
let rows = stmt.query_map([creator_id], map_share)?;
@@ -577,6 +641,35 @@ impl Db {
// ---------- settings ----------
/// Folders excluded from search, as paths relative to the server root.
///
/// Stored as one JSON array in a settings row. A table of its own would
/// be overkill for a hand-edited list read once per search.
pub async fn search_excludes(&self) -> DbResult<Vec<String>> {
let raw = self.get_setting("search_excludes").await?;
// Normalised on read as well as on write. A value edited straight
// into the database would otherwise never match: `is_excluded`
// compares against paths with no slash at either end.
let clean = |v: Vec<String>| -> Vec<String> {
v.into_iter()
.map(|p| p.trim().replace('\\', "/").trim_matches('/').to_string())
.filter(|p| !p.is_empty() && p != ".")
.collect()
};
// A hand-edited, unparseable value falls back to no exclusions,
// the same as an absent row.
Ok(raw
.as_deref()
.and_then(|v| serde_json::from_str::<Vec<String>>(v).ok())
.map(clean)
.unwrap_or_default())
}
pub async fn set_search_excludes(&self, paths: &[String]) -> DbResult<()> {
let json = serde_json::to_string(paths).unwrap_or_else(|_| "[]".to_string());
self.set_setting("search_excludes", &json).await
}
pub async fn get_setting(&self, key: &str) -> DbResult<Option<String>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached("SELECT value FROM settings WHERE key = ?1")?;
@@ -644,6 +737,7 @@ fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
mode: r.get::<_, SqlMode>(5)?.0,
created_at: r.get(6)?,
expires_at: r.get(7)?,
password_hash: r.get(8)?,
})
}
@@ -653,6 +747,16 @@ static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
crate::auth::hash_password(&crate::auth::random_token()).expect("argon2 hash")
});
/// How long an unlock row outlives its cookie. The cookie dies with the
/// browser, so this only bounds the rows left behind by closed sessions.
const UNLOCK_MAX_AGE_DAYS: i64 = 7;
/// The timestamp an unlock row must be newer than to survive a cleanup.
fn expiry_cutoff() -> String {
(chrono::Utc::now() - chrono::Duration::days(UNLOCK_MAX_AGE_DAYS))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
@@ -990,6 +1094,7 @@ mod tests {
mode: Mode::Ro,
created_at: "2024-01-01T00:00:00Z".into(),
expires_at: expires_at.map(str::to_string),
password_hash: None,
}
}
@@ -1006,7 +1111,7 @@ mod tests {
async fn shares_crud() {
let (db, admin) = db_with_admin().await;
let s1 = db
.create_share(admin.id, "tok-a", "docs", false, Mode::Ro, None)
.create_share(admin.id, "tok-a", "docs", false, Mode::Ro, None, None)
.await
.unwrap();
let s2 = db
@@ -1017,6 +1122,7 @@ mod tests {
true,
Mode::Rw,
Some("2999-01-01T00:00:00Z"),
None,
)
.await
.unwrap();
@@ -1043,11 +1149,60 @@ mod tests {
assert!(!db.delete_share(s1.id, admin.id).await.unwrap());
}
/// The unlock token is what a visitor's cookie carries, so an unlock
/// that opened the wrong share would be a full bypass of the password.
#[tokio::test]
async fn share_unlocks_are_bound_to_one_share() {
let (db, admin) = db_with_admin().await;
let a = db
.create_share(
admin.id,
"tok-a",
"docs",
false,
Mode::Ro,
None,
Some("hash"),
)
.await
.unwrap();
let b = db
.create_share(
admin.id,
"tok-b",
"other",
false,
Mode::Ro,
None,
Some("hash"),
)
.await
.unwrap();
assert_eq!(
db.share_by_token("tok-a")
.await
.unwrap()
.unwrap()
.password_hash,
Some("hash".to_string())
);
let unlock = db.create_share_unlock(a.id).await.unwrap();
assert!(db.share_unlock_valid(&unlock, a.id).await.unwrap());
assert!(!db.share_unlock_valid(&unlock, b.id).await.unwrap());
assert!(!db.share_unlock_valid("nonsense", a.id).await.unwrap());
// Deleting the share takes its unlocks with it, so a re-created
// share that happened to reuse the id could not inherit them.
assert!(db.delete_share(a.id, admin.id).await.unwrap());
assert!(!db.share_unlock_valid(&unlock, a.id).await.unwrap());
}
#[tokio::test]
async fn revoking_a_path_takes_its_descendants_only() {
let (db, admin) = db_with_admin().await;
let mk = async |token: &str, target: &str| {
db.create_share(admin.id, token, target, false, Mode::Ro, None)
db.create_share(admin.id, token, target, false, Mode::Ro, None, None)
.await
.unwrap();
};
▾Mserver/tests/api_search.rs
@@ -163,3 +163,123 @@ async fn hidden_and_gitignored_entries_are_searched() {
);
assert!(paths.contains(&"ignoredfile.log".to_string()), "{paths:?}");
}
/// An excluded folder is invisible to search: not as a name hit, and not as
/// a source of content hits from inside it.
#[tokio::test]
async fn excluded_folders_never_appear_in_results() {
let env = Env::new().await;
let admin = env.admin().await;
// Baseline: "docs" and the file under it are both findable.
let r = admin
.get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
.await;
let paths: Vec<String> = events(&r.text())
.iter()
.filter(|e| e["type"] == "file")
.map(|e| e["path"].as_str().unwrap_or_default().to_string())
.collect();
assert!(paths.contains(&"docs/inner/hello.txt".to_string()));
let r = admin
.put_json(
"/api/admin/settings",
&serde_json::json!({
"allow_writable_shares": false,
// Normalisation: the stored form has no surrounding slashes.
"search_excludes": ["/docs/"],
}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "settings: {}", r.text());
assert_eq!(r.json()["search_excludes"][0], "docs");
let r = admin
.get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
.await;
let evs = events(&r.text());
for e in &evs {
let p = e["path"].as_str().unwrap_or_default();
assert!(
!p.starts_with("docs"),
"excluded folder leaked into results: {e}"
);
}
// A search for the folder's own name finds nothing either.
let r = admin
.get(&format!("/api/search?q=docs&scope=name&root={ROOT}"))
.await;
assert!(
!events(&r.text()).iter().any(|e| e["type"] == "file"),
"the excluded folder itself was still listed"
);
// Everything outside it is untouched.
let r = admin
.get(&format!("/api/search?q=main&scope=name&root={ROOT}"))
.await;
assert!(
events(&r.text()).iter().any(|e| e["path"] == "src/main.rs"),
"an unrelated folder was excluded too"
);
}
/// "." would exclude the whole root, which turns search off rather than
/// narrowing it. Blank and duplicate entries are dropped the same way.
#[tokio::test]
async fn exclude_list_is_normalised() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin
.put_json(
"/api/admin/settings",
&serde_json::json!({
"allow_writable_shares": false,
"search_excludes": [".", "", " ", "docs", "docs/", "/src"],
}),
)
.await;
assert_eq!(r.status, StatusCode::OK);
let got = r.json();
let list: Vec<String> = got["search_excludes"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(list, vec!["docs".to_string(), "src".to_string()]);
// And it survives a round trip.
let r = admin.get("/api/admin/settings").await;
assert_eq!(r.json()["search_excludes"], got["search_excludes"]);
}
/// Windows-style separators must survive normalisation. Trimming the
/// slashes before converting the backslashes left `\docs\` stored as
/// `/docs/`, which then matched nothing.
#[tokio::test]
async fn backslash_paths_are_normalised_before_trimming() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin
.put_json(
"/api/admin/settings",
&serde_json::json!({
"allow_writable_shares": false,
"search_excludes": ["\\docs\\"],
}),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["search_excludes"][0], "docs");
// And it actually excludes.
let r = admin
.get(&format!("/api/search?q=hello&scope=both&root={ROOT}"))
.await;
for e in events(&r.text()) {
let p = e["path"].as_str().unwrap_or_default();
assert!(!p.starts_with("docs"), "not excluded: {e}");
}
}
▾Mserver/tests/api_shares.rs
@@ -591,3 +591,191 @@ async fn mutating_a_target_revokes_its_shares() {
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert!(alive(&copied).await, "a copy must leave the share alone");
}
/// A password must gate the files, not only the share page. The file API
/// with `?share=<token>` never goes through the resolve endpoint.
#[tokio::test]
async fn a_password_locks_the_share_and_its_files() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin
.post_json(
"/api/shares",
&json!({"root_id": 1, "path": "docs", "password": "hunter22"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "create: {}", r.text());
let s = r.json();
assert_eq!(s["has_password"], true);
let token = s["token"].as_str().unwrap().to_string();
let root_id = s["root_id"].as_i64().unwrap();
let anon = Client::new(env.app.clone());
// The share page is locked, with its own status so the client can tell
// it apart from a dead link.
let r = anon.get(&format!("/api/share/{token}")).await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
assert_eq!(r.json()["code"], "err_share_locked");
// ... and so is every file call carrying the token.
let r = anon
.get(&format!("/api/files/{root_id}?share={token}"))
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED, "listing was not gated");
let r = anon
.get(&format!(
"/api/files/{root_id}/a.txt?share={token}&action=download"
))
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED, "download was not gated");
// Wrong password: no cookie, still locked.
let r = anon
.post_json(
&format!("/api/share/{token}/unlock"),
&json!({"password": "wrong-one"}),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
assert!(r.header("set-cookie").is_none());
// Right password: the unlock cookie comes back.
let r = anon
.post_json(
&format!("/api/share/{token}/unlock"),
&json!({"password": "hunter22"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "unlock: {}", r.text());
let cookie = r.header("set-cookie").expect("unlock sets a cookie");
let share_id = s["id"].as_i64().unwrap();
assert!(cookie.starts_with(&format!("fbng_share_{share_id}=")));
assert!(cookie.contains("HttpOnly"));
let value = cookie
.split(';')
.next()
.unwrap()
.split_once('=')
.unwrap()
.1
.to_string();
// With the cookie, both the page and the files open.
let hdr: &[(&str, &str)] = &[("cookie", &format!("fbng_share_{share_id}={value}"))];
let r = anon
.raw(
axum::http::Method::GET,
&format!("/api/share/{token}"),
hdr,
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::OK, "unlocked resolve: {}", r.text());
let r = anon
.raw(
axum::http::Method::GET,
&format!("/api/files/{root_id}?share={token}"),
hdr,
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::OK, "unlocked listing: {}", r.text());
// An unlock for one share must not open another.
let other = admin
.post_json(
"/api/shares",
&json!({"root_id": 1, "path": "docs/a.txt", "password": "hunter22"}),
)
.await
.json();
let other_token = other["token"].as_str().unwrap();
let other_root = other["root_id"].as_i64().unwrap();
let r = anon
.raw(
axum::http::Method::GET,
&format!("/api/files/{other_root}?share={other_token}"),
hdr,
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED, "cookie crossed shares");
}
/// A short password is refused, the same as for an account.
#[tokio::test]
async fn a_share_password_must_be_long_enough() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin
.post_json(
"/api/shares",
&json!({"root_id": 1, "path": "docs", "password": "short"}),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
assert_eq!(r.json()["code"], "err_password_short");
// Nothing was created.
assert!(
admin
.get("/api/shares")
.await
.json()
.as_array()
.unwrap()
.is_empty()
);
}
/// Unlocking a share that has no password is a client bug, not a way to mint
/// a cookie.
#[tokio::test]
async fn unlocking_an_open_share_is_rejected() {
let env = Env::new().await;
let admin = env.admin().await;
let s = share(&admin, "docs", false, None).await;
let token = s["token"].as_str().unwrap();
let anon = Client::new(env.app.clone());
let r = anon
.post_json(
&format!("/api/share/{token}/unlock"),
&json!({"password": "whatever"}),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
assert!(r.header("set-cookie").is_none());
}
/// A password-protected *file* share must open its viewer after the correct
/// password. The unlock response is the only one the client sees in that
/// flow, so it has to carry the file's kind just as `resolve` does.
#[tokio::test]
async fn unlocking_a_file_share_returns_its_kind() {
let env = Env::new().await;
let admin = env.admin().await;
let s = admin
.post_json(
"/api/shares",
&json!({"root_id": 1, "path": "docs/a.txt", "password": "hunter22"}),
)
.await
.json();
assert_eq!(s["is_file"], true);
let token = s["token"].as_str().unwrap();
let anon = Client::new(env.app.clone());
let r = anon
.post_json(
&format!("/api/share/{token}/unlock"),
&json!({"password": "hunter22"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "unlock: {}", r.text());
assert_eq!(
r.json()["kind"],
"text",
"unlock dropped the kind, so the share page would render nothing"
);
}
▾Mweb/app.css
@@ -84,11 +84,25 @@ body {
text-align: center;
}
/* Inside the app frame the topbar already took part of the viewport, so a
full 100vh here overflows and shows a scrollbar with nothing to scroll to.
Login and setup render .center-screen as the whole page, where the 100vh
above is right. */
.content > .center-screen {
min-height: 0;
flex: 1;
}
.auth-card {
width: 100%;
max-width: 380px;
}
/* The auth forms have a single action, so its button spans the form. */
.auth-card .btn-primary {
width: 100%;
}
.auth-card h1 {
margin: 0 0 8px;
font-size: 22px;
@@ -186,8 +200,9 @@ button:disabled {
cursor: default;
}
/* No width here on purpose. Full width belongs to the few places that ask
for it (.auth-card, .unlock-btn), not to every dialog's action row. */
.btn-primary {
width: 100%;
background: var(--accent);
/* One step away from the fill, per theme (see palette above), so the
button's edge reads the same as on neutral buttons. */
@@ -576,6 +591,35 @@ button:disabled {
gap: 6px;
}
/* Sort control in the toolbar. The select mirrors the app's other selects:
no native chrome, own arrow. */
.sort-label {
font-size: 13px;
color: var(--muted);
align-self: center;
margin-right: 2px;
}
.sort-select {
appearance: none;
width: auto;
padding: 7px 32px 7px 10px;
border: 1px solid var(--border);
background-color: var(--panel);
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 9px center;
background-size: 12px 12px;
color: var(--text);
font: inherit;
font-size: 13px;
}
.sort-select:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
.icon-btn {
padding: 7px 9px;
border: 1px solid var(--border);
@@ -1303,15 +1347,8 @@ button:disabled {
justify-content: center;
}
.file-nopreview .btn-primary {
width: auto;
margin-top: 14px;
}
/* Conflict / discard dialogs render above the file view. */
.editor-sub-overlay {
z-index: 101;
background: rgb(0 0 0 / 25%);
}
/* ---------------------------------------------------------------------------
* Download format dialog
* ------------------------------------------------------------------------- */
@@ -1391,7 +1428,6 @@ button:disabled {
.share-expiry-row label {
font-size: 13px;
color: var(--muted);
min-width: 52px;
}
.share-expiry-row select,
@@ -1404,6 +1440,62 @@ button:disabled {
font: inherit;
}
/* Optional password on the create-share form. Laid out like the expiry
row above it, so the form reads as one column of labelled fields. */
.share-pw-row {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 6px;
}
.share-pw-row label {
font-size: 13px;
color: var(--muted);
}
/* Expiry and password sit in one column, so their inputs line up. */
.share-expiry-row label,
.share-pw-row label {
min-width: 64px;
}
.share-pw-row input {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font: inherit;
}
.share-pw-hint {
font-size: 12px;
margin: 0 0 10px;
}
/* The password gate of a protected share: the whole page is this card. */
.unlock-card {
width: 320px;
max-width: calc(100vw - 32px);
padding: 20px;
text-align: left;
}
.unlock-card h2 {
margin: 0 0 6px;
font-size: 18px;
}
.unlock-card input {
margin-top: 14px;
}
.unlock-btn {
width: 100%;
margin-top: 12px;
}
.share-expiry-note {
margin: 4px 0 0;
font-size: 12.5px;
@@ -1631,6 +1723,50 @@ button:disabled {
box-sizing: border-box;
}
/* Search exclusions: a bordered block matching the setting row above it. */
.excludes-field {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
}
.excludes-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.exclude-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.exclude-path {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exclude-remove {
flex: none;
padding: 4px 6px;
}
.exclude-add {
align-self: flex-start;
}
.excludes-empty {
font-size: 13px;
margin: 0;
}
.field-hint {
font-size: 12px;
margin: -2px 0 8px;
▾Mweb/src/api.rs
@@ -16,7 +16,8 @@ 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, SHARES, Settings, UpdateUser,
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,
@@ -483,6 +484,7 @@ pub fn create_share(
path: &str,
writable: bool,
expires_at: Option<&str>,
password: Option<&str>,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request(
"POST",
@@ -492,6 +494,7 @@ pub fn create_share(
path: path.to_string(),
writable,
expires_at: expires_at.map(|s| s.to_string()),
password: password.map(|s| s.to_string()),
}),
)
}
@@ -500,13 +503,29 @@ pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp,
request("DELETE", format!("{SHARES}/{id}"), None::<()>)
}
/// Public: resolve a share (no session required).
/// Public: resolve a share (no session required). A password-protected
/// share answers 401 until [`unlock_share`] has run in this browser.
pub fn resolve_share(
token: &str,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request("GET", format!("{SHARE}/{token}"), None::<()>)
}
/// Public: submit a protected share's password. The proof of the unlock is
/// a cookie the server sets, so nothing has to be kept here.
pub fn unlock_share(
token: &str,
password: &str,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request(
"POST",
format!("{SHARE}/{token}{SHARE_UNLOCK_SUFFIX}"),
Some(UnlockShare {
password: password.to_string(),
}),
)
}
// ---------------------------------------------------------------------------
// Admin (milestone 7): user management + settings
// ---------------------------------------------------------------------------
@@ -570,14 +589,18 @@ pub fn get_admin_settings() -> impl std::future::Future<Output = Result<Settings
request("GET", ADMIN_SETTINGS.to_string(), None::<()>)
}
/// PUT replaces the whole settings object, so every setting has to be
/// passed. Omitting one would reset it.
pub fn update_admin_settings(
allow_writable_shares: bool,
search_excludes: Vec<String>,
) -> impl std::future::Future<Output = Result<Settings, ApiError>> {
request(
"PUT",
ADMIN_SETTINGS.to_string(),
Some(Settings {
allow_writable_shares,
search_excludes,
}),
)
}
▾Mweb/src/editor.rs
@@ -15,6 +15,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, ApiError};
use crate::cm;
use crate::components::icon::Icon;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show};
use crate::i18n;
use crate::icons::IconName;
@@ -324,23 +325,23 @@ pub fn Editor(
}}
// Conflict: the file changed on disk since it was opened.
// Closing it means "keep my version, decide later", so Escape and
// a backdrop click leave the editor open and dirty.
{move || {
if conflict.get() {
view! {
<div class="modal-overlay editor-sub-overlay" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<h2 class="modal-title">{i18n::tr(i18n::k::FILE_CHANGED)}</h2>
<p class="modal-message">{i18n::tr(i18n::k::MODIFIED_AFTER)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| load_latest_cb.run(())>
{i18n::tr(i18n::k::LOAD_LATEST)}
</button>
<button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
{i18n::tr(i18n::k::OVERWRITE)}
</button>
</div>
<Modal class="card" on_close=Callback::new(move |_| set_conflict.set(false))>
<h2 class="modal-title">{i18n::tr(i18n::k::FILE_CHANGED)}</h2>
<p class="modal-message">{i18n::tr(i18n::k::MODIFIED_AFTER)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| load_latest_cb.run(())>
{i18n::tr(i18n::k::LOAD_LATEST)}
</button>
<button class="btn btn-danger" on:click=move |_| overwrite_cb.run(())>
{i18n::tr(i18n::k::OVERWRITE)}
</button>
</div>
</div>
</Modal>
}
.into_view()
.into_any()
@@ -349,27 +350,26 @@ pub fn Editor(
}
}}
// Unsaved-changes confirmation.
// Unsaved-changes confirmation. Dismissing it is "keep editing":
// the destructive choice needs the button.
{move || {
if confirm_discard.get() {
view! {
<div class="modal-overlay editor-sub-overlay" on:click=move |_| set_confirm_discard.set(false)>
<div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<h2 class="modal-title">{i18n::tr(i18n::k::UNSAVED_CHANGES)}</h2>
<p class="modal-message">{i18n::tr(i18n::k::DISCARD_QUESTION)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| set_confirm_discard.set(false)>
{i18n::tr(i18n::k::KEEP_EDITING)}
</button>
<button class="btn btn-danger" on:click=move |_| {
set_confirm_discard.set(false);
close.run(());
}>
{i18n::tr(i18n::k::DISCARD)}
</button>
</div>
<Modal class="card" on_close=Callback::new(move |_| set_confirm_discard.set(false))>
<h2 class="modal-title">{i18n::tr(i18n::k::UNSAVED_CHANGES)}</h2>
<p class="modal-message">{i18n::tr(i18n::k::DISCARD_QUESTION)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| set_confirm_discard.set(false)>
{i18n::tr(i18n::k::KEEP_EDITING)}
</button>
<button class="btn btn-danger" on:click=move |_| {
set_confirm_discard.set(false);
close.run(());
}>
{i18n::tr(i18n::k::DISCARD)}
</button>
</div>
</div>
</Modal>
}
.into_view()
.into_any()
▾Mweb/src/i18n.rs
@@ -280,12 +280,19 @@ i18n_keys! {
ERR_SEARCH_FORBIDDEN = "err_search_forbidden" => "search requires a signed-in session",
ERR_SESSION_EXPIRED = "err_session_expired" => "session expired, please sign in again",
ERR_SHARE_EXPIRED = "err_share_expired" => "this share has expired",
ERR_SHARE_LOCKED = "err_share_locked" => "This share is password protected.",
ERR_SHARE_NO_PASSWORD = "err_share_no_password" => "This share has no password.",
ERR_SHARE_NOT_FOUND = "err_share_not_found" => "share not found",
ERR_SHARE_TOKEN_FORBIDDEN = "err_share_token_forbidden" => "a share token cannot be used here; sign in instead",
ERR_SHARE_WRONG_PASSWORD = "err_share_wrong_password" => "Wrong password.",
ERR_TOO_LARGE_PREVIEW = "err_too_large_preview" => "file too large to preview",
ERR_TOO_LARGE_SAVE = "err_too_large_save" => "file too large to save",
ERR_USER_EXISTS = "err_user_exists" => "a user with that name already exists",
ERR_USER_NOT_FOUND = "err_user_not_found" => "user not found",
EXCLUDE_DUP_ERR = "exclude_dup_err" => "That folder is already excluded.",
EXCLUDE_FOLDER = "exclude_folder" => "Exclude folder…",
EXCLUDE_HERE = "exclude_here" => "Exclude this folder",
EXCLUDE_ROOT_ERR = "exclude_root_err" => "The whole root cannot be excluded; that would turn search off.",
EXPIRES = "expires" => "Expires",
EXPIRES_AT = "expires_at" => "Expires {}",
EXPIRY_1D = "expiry_1d" => "1 day",
@@ -392,6 +399,8 @@ i18n_keys! {
SEARCH_BOTH = "search_both" => "Both",
SEARCH_COLLAPSE_ALL = "search_collapse_all" => "Collapse all",
SEARCH_CONTENT = "search_content" => "Content",
SEARCH_EXCLUDES = "search_excludes" => "Excluded from search",
SEARCH_EXCLUDES_EMPTY = "search_excludes_empty" => "No folders are excluded.",
SEARCH_EXPAND_ALL = "search_expand_all" => "Expand all",
SEARCH_FAILED = "search_failed" => "The search failed.",
SEARCH_GOTO = "search_goto" => "Show in folder",
@@ -432,10 +441,13 @@ i18n_keys! {
SHARE_EXPIRED = "share_expired" => "Share expired",
SHARE_EXPIRED_MSG = "share_expired_msg" => "This share link is no longer active.",
SHARE_LINK = "share_link" => "Share link",
SHARE_LOCKED_TITLE = "share_locked_title" => "Password required",
SHARE_NOT_FOUND = "share_not_found" => "Share not found",
SHARE_NOT_FOUND_MSG = "share_not_found_msg" => "This link is invalid or the share was removed.",
SHARE_PASSWORD_HINT = "share_password_hint" => "Optional. Visitors must enter it before the share opens, at least 8 characters.",
SHARE_PERM_RO = "share_perm_ro" => "Anyone with this link can read “{}”.",
SHARE_PERM_RW = "share_perm_rw" => "Anyone with this link can read and write “{}”.",
SHARE_PROTECTED = "share_protected" => "Password protected",
SHARE_TITLE = "share_title" => "Share {}",
SHARES = "shares" => "Shares",
SHARES_DELETE_ERR = "shares_delete_err" => "Could not delete shares",
@@ -446,11 +458,15 @@ i18n_keys! {
SINGLE_CLICK_LABEL = "single_click_label" => "Use single click to open",
SIZE = "size" => "Size",
SOME_FILES_EXIST = "some_files_exist" => "Some files already exist",
SORT_ASC = "sort_asc" => "Ascending",
SORT_BY = "sort_by" => "Sort by",
SORT_DESC = "sort_desc" => "Descending",
THEME_AUTO = "theme_auto" => "Auto",
THEME_DARK = "theme_dark" => "Dark",
THEME_LIGHT = "theme_light" => "Light",
THEME_TITLE = "theme_title" => "Theme: {} (click to switch)",
TYPE = "type" => "Type",
UNLOCK = "unlock" => "Open share",
UNSAVED_CHANGES = "unsaved_changes" => "Unsaved changes",
UNTIL = "until" => "Until",
UPLOAD_COMPLETE = "upload_complete" => "Upload complete",
@@ -648,11 +664,17 @@ const DE: &[(&str, &str)] = &[
"Sitzung abgelaufen, bitte erneut anmelden",
),
(k::ERR_SHARE_EXPIRED, "diese Freigabe ist abgelaufen"),
(k::ERR_SHARE_LOCKED, "Diese Freigabe ist passwortgeschützt."),
(
k::ERR_SHARE_NO_PASSWORD,
"Diese Freigabe hat kein Passwort.",
),
(k::ERR_SHARE_NOT_FOUND, "Freigabe nicht gefunden"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"ein Freigabetoken kann hier nicht verwendet werden; bitte anmelden",
),
(k::ERR_SHARE_WRONG_PASSWORD, "Falsches Passwort."),
(k::ERR_TOO_LARGE_PREVIEW, "Datei zu groß für eine Vorschau"),
(k::ERR_TOO_LARGE_SAVE, "Datei zu groß zum Speichern"),
(
@@ -660,6 +682,16 @@ const DE: &[(&str, &str)] = &[
"ein Benutzer mit diesem Namen existiert bereits",
),
(k::ERR_USER_NOT_FOUND, "Benutzer nicht gefunden"),
(
"exclude_dup_err",
"Dieser Ordner ist bereits ausgeschlossen.",
),
("exclude_folder", "Ordner ausschließen…"),
("exclude_here", "Diesen Ordner ausschließen"),
(
"exclude_root_err",
"Das gesamte Stammverzeichnis kann nicht ausgeschlossen werden; das würde die Suche abschalten.",
),
("expires", "Läuft ab"),
("expires_at", "Läuft ab: {}"),
("expiry_1d", "1 Tag"),
@@ -805,6 +837,8 @@ const DE: &[(&str, &str)] = &[
("search_both", "Beides"),
("search_collapse_all", "Alle einklappen"),
("search_content", "Inhalt"),
("search_excludes", "Von der Suche ausgeschlossen"),
("search_excludes_empty", "Keine Ordner ausgeschlossen."),
("search_expand_all", "Alle ausklappen"),
("search_failed", "Die Suche ist fehlgeschlagen."),
("search_goto", "Im Ordner anzeigen"),
@@ -869,16 +903,22 @@ const DE: &[(&str, &str)] = &[
"Dieser Freigabelink ist nicht mehr aktiv.",
),
("share_link", "Freigabelink"),
("share_locked_title", "Passwort erforderlich"),
("share_not_found", "Freigabe nicht gefunden"),
(
"share_not_found_msg",
"Dieser Link ist ungültig oder die Freigabe wurde entfernt.",
),
(
"share_password_hint",
"Optional. Besucher müssen es eingeben, bevor die Freigabe geöffnet wird, mindestens 8 Zeichen.",
),
("share_perm_ro", "Jeder mit diesem Link kann „{}“ lesen."),
(
"share_perm_rw",
"Jeder mit diesem Link kann „{}“ lesen und schreiben.",
),
("share_protected", "Passwortgeschützt"),
("share_title", "{} teilen"),
("shares", "Freigaben"),
(
@@ -901,11 +941,15 @@ const DE: &[(&str, &str)] = &[
("single_click_label", "Einzelklick öffnet Einträge"),
("size", "Größe"),
("some_files_exist", "Einige Dateien existieren bereits"),
("sort_asc", "Aufsteigend"),
("sort_by", "Sortieren nach"),
("sort_desc", "Absteigend"),
("theme_auto", "Auto"),
("theme_dark", "Dunkel"),
("theme_light", "Hell"),
("theme_title", "Design: {} (klicken zum Wechseln)"),
("type", "Typ"),
("unlock", "Freigabe öffnen"),
("unsaved_changes", "Ungespeicherte Änderungen"),
("until", "Bis"),
("upload_complete", "Hochladen abgeschlossen"),
@@ -1100,11 +1144,20 @@ const FR: &[(&str, &str)] = &[
"session expirée, veuillez vous connecter à nouveau",
),
(k::ERR_SHARE_EXPIRED, "ce partage a expiré"),
(
k::ERR_SHARE_LOCKED,
"Ce partage est protégé par un mot de passe.",
),
(
k::ERR_SHARE_NO_PASSWORD,
"Ce partage n'a pas de mot de passe.",
),
(k::ERR_SHARE_NOT_FOUND, "partage introuvable"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"un jeton de partage ne peut pas être utilisé ici ; connectez-vous à la place",
),
(k::ERR_SHARE_WRONG_PASSWORD, "Mot de passe incorrect."),
(
k::ERR_TOO_LARGE_PREVIEW,
"fichier trop volumineux pour l'aperçu",
@@ -1115,6 +1168,13 @@ const FR: &[(&str, &str)] = &[
),
(k::ERR_USER_EXISTS, "un utilisateur avec ce nom existe déjà"),
(k::ERR_USER_NOT_FOUND, "utilisateur introuvable"),
("exclude_dup_err", "Ce dossier est déjà exclu."),
("exclude_folder", "Exclure un dossier…"),
("exclude_here", "Exclure ce dossier"),
(
"exclude_root_err",
"La racine entière ne peut pas être exclue ; cela désactiverait la recherche.",
),
("expires", "Expire"),
("expires_at", "Expire le {}"),
("expiry_1d", "1 jour"),
@@ -1263,6 +1323,8 @@ const FR: &[(&str, &str)] = &[
("search_both", "Les deux"),
("search_collapse_all", "Tout replier"),
("search_content", "Contenu"),
("search_excludes", "Exclu de la recherche"),
("search_excludes_empty", "Aucun dossier exclu."),
("search_expand_all", "Tout déplier"),
("search_failed", "La recherche a échoué."),
("search_goto", "Afficher dans le dossier"),
@@ -1324,11 +1386,16 @@ const FR: &[(&str, &str)] = &[
("share_expired", "Partage expiré"),
("share_expired_msg", "Ce lien de partage n'est plus actif."),
("share_link", "Lien de partage"),
("share_locked_title", "Mot de passe requis"),
("share_not_found", "Partage introuvable"),
(
"share_not_found_msg",
"Ce lien est invalide ou le partage a été supprimé.",
),
(
"share_password_hint",
"Facultatif. Les visiteurs doivent le saisir avant d'ouvrir le partage, au moins 8 caractères.",
),
(
"share_perm_ro",
"Toute personne possédant ce lien peut lire « {} ».",
@@ -1337,6 +1404,7 @@ const FR: &[(&str, &str)] = &[
"share_perm_rw",
"Toute personne possédant ce lien peut lire et modifier « {} ».",
),
("share_protected", "Protégé par mot de passe"),
("share_title", "Partager {}"),
("shares", "Partages"),
("shares_delete_err", "Impossible de supprimer les partages"),
@@ -1356,11 +1424,15 @@ const FR: &[(&str, &str)] = &[
("single_click_label", "Ouvrir au clic simple"),
("size", "Taille"),
("some_files_exist", "Certains fichiers existent déjà"),
("sort_asc", "Croissant"),
("sort_by", "Trier par"),
("sort_desc", "Décroissant"),
("theme_auto", "Auto"),
("theme_dark", "Sombre"),
("theme_light", "Clair"),
("theme_title", "Thème : {} (cliquer pour changer)"),
("type", "Type"),
("unlock", "Ouvrir le partage"),
("unsaved_changes", "Modifications non enregistrées"),
("until", "Jusqu'au"),
("upload_complete", "Téléversement terminé"),
▾Mweb/src/icons.rs
@@ -91,6 +91,10 @@ pub enum IconName {
ThemeDark,
/// material-symbols:more-vert
MoreVert,
/// material-symbols:arrow-upward
SortAsc,
/// material-symbols:arrow-downward
SortDesc,
}
impl IconName {
@@ -214,6 +218,12 @@ impl IconName {
Self::MoreVert => {
r#"<path fill="currentColor" d="M12 20q-.825 0-1.412-.587T10 18t.588-1.412T12 16t1.413.588T14 18t-.587 1.413T12 20m0-6q-.825 0-1.412-.587T10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14m0-6q-.825 0-1.412-.587T10 6t.588-1.412T12 4t1.413.588T14 6t-.587 1.413T12 8"/>"#
}
Self::SortAsc => {
r#"<path fill="currentColor" d="M11 20V7.825l-5.6 5.6L4 12l8-8l8 8l-1.4 1.425l-5.6-5.6V20z"/>"#
}
Self::SortDesc => {
r#"<path fill="currentColor" d="M11 4v12.175l-5.6-5.6L4 12l8 8l8-8l-1.4-1.425l-5.6 5.6V4z"/>"#
}
}
}
}
▾Mweb/src/util.rs
@@ -134,6 +134,129 @@ impl ViewMode {
}
}
/// What a listing is ordered by. Folders always sort before files, so a
/// size or date order does not scatter them through the listing.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SortKey {
Name,
Size,
Modified,
}
impl SortKey {
pub fn as_str(self) -> &'static str {
match self {
SortKey::Name => "name",
SortKey::Size => "size",
SortKey::Modified => "modified",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"name" => Some(SortKey::Name),
"size" => Some(SortKey::Size),
"modified" => Some(SortKey::Modified),
_ => None,
}
}
}
/// A sort key plus its direction, persisted like [`ViewMode`].
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct SortSpec {
pub key: SortKey,
pub asc: bool,
}
impl Default for SortSpec {
fn default() -> Self {
// The server's own order, so the first paint after a fetch does not
// reshuffle.
Self {
key: SortKey::Name,
asc: true,
}
}
}
impl SortSpec {
const KEY: &'static str = "fbng.sort";
pub fn load() -> Self {
let raw = storage_get(Self::KEY).unwrap_or_default();
let (key, dir) = raw.split_once(':').unwrap_or(("", ""));
match SortKey::parse(key) {
Some(key) => Self {
key,
asc: dir != "desc",
},
None => Self::default(),
}
}
pub fn save(self) {
let dir = if self.asc { "asc" } else { "desc" };
storage_set(Self::KEY, &format!("{}:{dir}", self.key.as_str()));
}
/// Order `entries` in place.
///
/// Name is compared case-insensitively, with the raw name as the
/// tie-breaker so two names differing only in case keep a stable order.
/// Folders have no meaningful size, so they fall back to the name order
/// among themselves.
pub fn apply(self, entries: &mut [crate::api::Entry]) {
match self.key {
SortKey::Name => entries.sort_by(|a, b| {
a.is_dir
.cmp(&b.is_dir)
.reverse()
.then_with(|| cmp_name(a, b).dir(self.asc))
}),
SortKey::Size => entries.sort_by(|a, b| {
a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
if a.is_dir {
cmp_name(a, b)
} else {
a.size
.cmp(&b.size)
.dir(self.asc)
.then_with(|| cmp_name(a, b))
}
})
}),
SortKey::Modified => entries.sort_by(|a, b| {
a.is_dir.cmp(&b.is_dir).reverse().then_with(|| {
// RFC 3339 UTC with a fixed width, so bytewise order is
// chronological order.
a.mtime
.cmp(&b.mtime)
.dir(self.asc)
.then_with(|| cmp_name(a, b))
})
}),
}
}
}
fn cmp_name(a: &crate::api::Entry, b: &crate::api::Entry) -> std::cmp::Ordering {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then_with(|| a.name.cmp(&b.name))
}
trait OrderingDir {
fn dir(self, asc: bool) -> std::cmp::Ordering;
}
impl OrderingDir for std::cmp::Ordering {
fn dir(self, asc: bool) -> std::cmp::Ordering {
if asc { self } else { self.reverse() }
}
}
/// Entries rendered at once in a listing, and how many more each click of
/// the "show more" row reveals. Rendering a row costs a fixed amount of
/// reactive-graph setup, so the first paint stays well under a frame and the
@@ -188,6 +311,83 @@ pub fn page_footer(
mod tests {
use super::*;
fn entry(name: &str, is_dir: bool, size: u64, mtime: &str) -> crate::api::Entry {
crate::api::Entry {
name: name.to_string(),
is_dir,
size,
mtime: mtime.to_string(),
kind: if is_dir {
api_types::FileKind::Dir
} else {
api_types::FileKind::Binary
},
}
}
/// This order is what the browser's arrow keys and Shift+click ranges
/// walk, so a wrong order moves the wrong file.
#[test]
fn sort_keeps_folders_first_in_every_order() {
let mut v = vec![
entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
entry("Zdir", true, 0, "2026-01-01T00:00:00Z"),
entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
];
for key in [SortKey::Name, SortKey::Size, SortKey::Modified] {
for asc in [true, false] {
SortSpec { key, asc }.apply(&mut v);
assert!(v[0].is_dir, "{key:?} asc={asc} put a file first");
}
}
}
#[test]
fn sort_orders_by_the_chosen_key() {
let mut v = vec![
entry("b.txt", false, 10, "2026-01-02T00:00:00Z"),
entry("a.txt", false, 30, "2026-01-03T00:00:00Z"),
entry("c.txt", false, 20, "2026-01-01T00:00:00Z"),
];
let names = |v: &[crate::api::Entry]| v.iter().map(|e| e.name.clone()).collect::<Vec<_>>();
SortSpec {
key: SortKey::Name,
asc: true,
}
.apply(&mut v);
assert_eq!(names(&v), ["a.txt", "b.txt", "c.txt"]);
SortSpec {
key: SortKey::Name,
asc: false,
}
.apply(&mut v);
assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
SortSpec {
key: SortKey::Size,
asc: true,
}
.apply(&mut v);
assert_eq!(names(&v), ["b.txt", "c.txt", "a.txt"]);
SortSpec {
key: SortKey::Modified,
asc: true,
}
.apply(&mut v);
assert_eq!(names(&v), ["c.txt", "b.txt", "a.txt"]);
}
#[test]
fn sort_spec_round_trips_through_its_stored_form() {
// No localStorage in the test runner, so only the parse half runs
// here. `load` falls back to the default.
assert_eq!(SortKey::parse("size"), Some(SortKey::Size));
assert_eq!(SortKey::parse("bogus"), None);
assert_eq!(SortSpec::default().key, SortKey::Name);
assert!(SortSpec::default().asc);
}
#[test]
fn format_date_drops_seconds_and_zone() {
assert_eq!(format_date("2026-09-11T12:34:56Z"), "2026-09-11 12:34");
▾Mweb/src/views/admin.rs
@@ -50,18 +50,17 @@ impl SettingsTab {
}
}
/// One "setting" tab body: a checkbox with description and a busy save
/// button. Both tabs (profile, server) share exactly this shape, so the
/// markup lives here once.
/// One labelled checkbox row for a setting.
///
/// The save button belongs to the tab, not the row. The server tab writes
/// all of its settings in one PUT, so a per-row button would save the other
/// rows' unsaved state too.
#[component]
fn SettingToggle(
label: &'static str,
desc: &'static str,
value: ReadSignal<Option<bool>>,
set_value: WriteSignal<Option<bool>>,
busy: ReadSignal<bool>,
save: Callback<()>,
save_label: &'static str,
) -> impl IntoView {
view! {
{move || match value.get() {
@@ -89,15 +88,6 @@ fn SettingToggle(
}
/>
</label>
<div class="modal-actions">
<button
class="btn btn-primary"
disabled=move || busy.get()
on:click=move |_| save.run(())
>
{move || if busy.get() { i18n::t(i18n::k::SAVING).to_string() } else { save_label.to_string() }}
</button>
</div>
}
.into_view()
.into_any(),
@@ -106,7 +96,13 @@ fn SettingToggle(
}
#[component]
pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
pub fn SettingsView(
me: ReadSignal<Option<Me>>,
set_me: WriteSignal<Option<Me>>,
/// The folder picker for the search exclusions is rendered at the shell
/// level, like every other dialog.
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let is_admin = move || me.get().and_then(|m| m.user).is_some_and(|u| u.is_admin);
// The shell re-creates this view when /me changes (saving resets it), so
@@ -159,11 +155,14 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
});
});
// --- server: allow writable shares (admin only) ------------------------
// --- server settings (admin only) --------------------------------------
let (value, set_value) = signal(Option::<bool>::None);
let (excludes, set_excludes) = signal(Vec::<String>::new());
let (excl_error, set_excl_error) = signal(Option::<String>::None);
let (busy, set_busy) = signal(false);
{
let set = set_value;
let set_ex = set_excludes;
let toast2 = toast;
spawn_local(async move {
// Non-admins don't have a Server tab; don't even probe the
@@ -172,21 +171,54 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
return;
}
match api::get_admin_settings().await {
Ok(s) => set.set(Some(s.allow_writable_shares)),
Ok(s) => {
set.set(Some(s.allow_writable_shares));
set_ex.set(s.search_excludes);
}
Err(e) => show_error(toast2, e.to_string()),
}
});
}
// Opens the same folder picker the user editor uses, over the whole
// server root (an exclusion is not tied to any one user's folders).
let add_exclude = move |_| {
set_excl_error.set(None);
set_dialog.set(Some(Dialog::Picker {
title: i18n::t(i18n::k::EXCLUDE_FOLDER).to_string(),
confirm: i18n::t(i18n::k::EXCLUDE_HERE).to_string(),
roots: vec![admin_root()],
root: api_types::ADMIN_ROOT,
dir: String::new(),
for_write: false,
on_pick: Callback::new(move |(_, dir): (i64, String)| {
// An empty dir is the server root. Excluding it would
// switch search off altogether.
if dir.is_empty() {
set_excl_error.set(Some(i18n::t(i18n::k::EXCLUDE_ROOT_ERR).to_string()));
return;
}
let mut v = excludes.get();
if v.contains(&dir) {
set_excl_error.set(Some(i18n::t(i18n::k::EXCLUDE_DUP_ERR).to_string()));
return;
}
v.push(dir);
set_excludes.set(v);
set_excl_error.set(None);
}),
}));
};
let save = move |_| {
if busy.get() {
return;
}
let Some(v) = value.get() else { return };
let ex = excludes.get();
set_busy.set(true);
let toast2 = toast;
let set_me2 = set_me;
spawn_local(async move {
match api::update_admin_settings(v).await {
match api::update_admin_settings(v, ex).await {
Ok(_) => {
show(toast2, i18n::t(i18n::k::SETTINGS_SAVED).to_string());
// Re-fetch this session's /me so the share dialog (and
@@ -300,10 +332,75 @@ pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
desc=i18n::t(i18n::k::ALLOW_RW_SHARES_DESC)
value=value
set_value=set_value
busy=busy
save=Callback::new(save)
save_label=i18n::t(i18n::k::SAVE_SETTINGS)
/>
<div class="excludes-field">
<span class="setting-label">{i18n::tr(i18n::k::SEARCH_EXCLUDES)}</span>
<div class="excludes-list">
{move || {
let list = excludes.get();
if list.is_empty() {
return view! {
<p class="muted excludes-empty">
{i18n::tr(i18n::k::SEARCH_EXCLUDES_EMPTY)}
</p>
}
.into_view()
.into_any();
}
list.iter()
.enumerate()
.map(|(i, path)| {
view! {
<div class="exclude-row">
<Icon name=IconName::Folder class="ic-row".to_string()/>
<span class="exclude-path">{path.clone()}</span>
<button
class="icon-btn exclude-remove"
title=i18n::t(i18n::k::REMOVE_FOLDER)
aria-label=i18n::t(i18n::k::REMOVE_FOLDER)
on:click=move |_| {
let mut v = excludes.get();
if i < v.len() {
v.remove(i);
}
set_excludes.set(v);
set_excl_error.set(None);
}
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
</div>
}
})
.collect::<Vec<_>>()
.into_view()
.into_any()
}}
</div>
<button class="btn btn-sm exclude-add" on:click=add_exclude>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{i18n::tr(i18n::k::EXCLUDE_FOLDER)}
</button>
{move || {
let Some(e) = excl_error.get() else {
return view! {}.into_any();
};
view! { <p class="dialog-error">{e}</p> }.into_view().into_any()
}}
</div>
<div class="modal-actions">
<button
class="btn btn-primary"
disabled=move || busy.get() || value.get().is_none()
on:click=move |_| save(())
>
{move || if busy.get() {
i18n::t(i18n::k::SAVING).to_string()
} else {
i18n::t(i18n::k::SAVE_SETTINGS).to_string()
}}
</button>
</div>
</Show>
</div>
}
▾Mweb/src/views/browser.rs
@@ -17,7 +17,7 @@ use crate::i18n;
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::util::{FILE_PAGE, ViewMode, format_date, format_size, page_footer};
use crate::util::{FILE_PAGE, SortKey, SortSpec, ViewMode, format_date, format_size, page_footer};
use crate::views::dialogs::{Dialog, Op};
use crate::views::file_view::{FileView, UnsupportedTarget};
@@ -85,6 +85,7 @@ pub fn Browser(
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (view_mode, set_view_mode) = signal(ViewMode::load());
let (sort, set_sort) = signal(SortSpec::load());
let (list_state, set_list_state) = signal(ListState::Loading);
let (ctx, set_ctx) = signal(Option::<CtxMenu>::None);
// Long-lived owner for callbacks created inside short-lived scopes
@@ -102,6 +103,22 @@ pub fn Browser(
// Bumped per fetch. A response whose generation is no longer current
// belongs to a folder the user has already navigated away from.
let fetch_gen = StoredValue::new(0u64);
// The listing in display order. Everything downstream indexes into
// this, not into the fetched order: Shift+click ranges and the arrow
// keys walk what the user sees. A Memo, so a re-sort costs one pass per
// change, not one per render.
let sorted = Memo::new(move |_| match list_state.get() {
ListState::Entries(list) => {
let mut entries = list.entries.clone();
sort.get().apply(&mut entries);
ListState::Entries(Arc::new(Listing {
entries,
truncated: list.truncated,
}))
}
other => other,
});
// The selected names as a set: every row asks "am I selected?" on each
// selection change, which is a scan of the selection per row otherwise.
let sel_names = Memo::new(move |_| {
@@ -115,7 +132,7 @@ pub fn Browser(
// Drop selected entries that disappeared from the listing (deleted,
// renamed, …).
Effect::new(move |_| {
list_state.with(|st| {
sorted.with(|st| {
let ListState::Entries(list) = st else {
return;
};
@@ -160,6 +177,59 @@ pub fn Browser(
},
);
// Left / Right in an open media preview steps to the previous or next
// previewable file, in the listing's current order.
//
// Only previewable files: the editor needs the arrow keys for its
// caret, and a folder has nothing to preview.
crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
let step: isize = match ev.key().as_str() {
"ArrowLeft" => -1,
"ArrowRight" => 1,
_ => return,
};
// A focused player owns its arrow keys (seek / volume), and a
// modifier means a browser shortcut (Alt+Left is history back).
let on_player = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.is_some_and(|el| matches!(el.tag_name().as_str(), "VIDEO" | "AUDIO"));
if on_player || ev.ctrl_key() || ev.meta_key() || ev.alt_key() || dialog.get().is_some() {
return;
}
let Some(FileView::Preview(cur, _)) = file_view.get() else {
return;
};
let ListState::Entries(list) = sorted.get() else {
return;
};
let shown: Vec<&Entry> = list
.entries
.iter()
.filter(|e| !e.is_dir && preview_kind(e.kind).is_some())
.collect();
let Some(at) = shown.iter().position(|e| e.name == cur.name) else {
return;
};
let next = at as isize + step;
if next < 0 || next as usize >= shown.len() {
return;
}
let e = shown[next as usize];
let Some(kind) = preview_kind(e.kind) else {
return;
};
ev.prevent_default();
open_file.set(Some(FileView::Preview(
PreviewTarget {
root_id: cur.root_id,
path: join_path(&loc.get().path, &e.name),
name: e.name.clone(),
},
kind,
)));
});
// Fetch the current directory whenever the location (or user) changes.
let fetch = Callback::new(move |_| {
let Some(me) = me.get() else {
@@ -233,10 +303,12 @@ pub fn Browser(
me,
root,
loc,
list_state,
sorted,
shown,
view_mode,
set_view_mode,
sort,
set_sort,
set_ctx,
selected,
sel_names,
@@ -306,10 +378,12 @@ fn file_browser(
me: ReadSignal<Option<Me>>,
root: &RootInfo,
loc: ReadSignal<Location>,
list_state: ReadSignal<ListState>,
list_state: Memo<ListState>,
shown: RwSignal<usize>,
view_mode: ReadSignal<ViewMode>,
set_view_mode: WriteSignal<ViewMode>,
sort: ReadSignal<SortSpec>,
set_sort: WriteSignal<SortSpec>,
set_ctx: WriteSignal<Option<CtxMenu>>,
selected: ReadSignal<Vec<Entry>>,
sel_names: Memo<HashSet<String>>,
@@ -385,6 +459,69 @@ fn file_browser(
<Icon name=IconName::Refresh class="ic-btn".to_string()/>
</button>
<div class="toolbar-right">
<label class="sort-label" for="sort-key">{i18n::tr(i18n::k::SORT_BY)}</label>
<select
id="sort-key"
class="sort-select"
on:change=move |ev| {
let Some(v) = crate::util::select_value(&ev) else { return };
let Some(key) = SortKey::parse(&v) else { return };
let next = SortSpec { key, asc: sort.get().asc };
set_sort.set(next);
next.save();
}
>
{[
(SortKey::Name, i18n::k::NAME),
(SortKey::Size, i18n::k::SIZE),
(SortKey::Modified, i18n::k::MODIFIED),
]
.map(|(key, label)| {
view! {
<option
value=key.as_str()
selected=move || sort.get().key == key
>
{i18n::tr(label)}
</option>
}
})
.to_vec()}
</select>
<button
class="icon-btn"
title=move || {
// The title names the current order, not the one the
// click would switch to: the arrow already shows it.
if sort.get().asc {
i18n::t(i18n::k::SORT_ASC)
} else {
i18n::t(i18n::k::SORT_DESC)
}
}
aria-label=move || {
if sort.get().asc {
i18n::t(i18n::k::SORT_ASC)
} else {
i18n::t(i18n::k::SORT_DESC)
}
}
on:click=move |_| {
let cur = sort.get();
let next = SortSpec { key: cur.key, asc: !cur.asc };
set_sort.set(next);
next.save();
}
>
{move || {
let name = if sort.get().asc {
IconName::SortAsc
} else {
IconName::SortDesc
};
icon_svg(name, "ic-btn")
}}
</button>
<button
class=move || {
if view_mode.get() == ViewMode::Grid {
@@ -528,6 +665,85 @@ fn ctx_menu_handler(
}
}
/// Items per row in the grid.
///
/// Read from the DOM on every call, not cached: the grid is CSS
/// `auto-fill`, so only the browser knows the column count, and a cached one
/// would go stale on the next resize.
fn grid_columns(container: &web_sys::Element) -> usize {
let kids = container.children();
let first = kids
.item(0)
.and_then(|c| c.dyn_into::<web_sys::HtmlElement>().ok());
let Some(first) = first else { return 1 };
let top = first.offset_top();
let mut cols = 0usize;
for i in 0..kids.length() {
let Some(el) = kids
.item(i)
.and_then(|c| c.dyn_into::<web_sys::HtmlElement>().ok())
else {
break;
};
if el.offset_top() != top {
break;
}
cols += 1;
}
cols.max(1)
}
/// Move keyboard focus to the item at `idx` inside `container`.
///
/// `focus()` scrolls the element into view on its own, so an item revealed
/// by the arrow keys does not need its own scrolling.
fn focus_item(container: &web_sys::Element, idx: usize) {
if let Some(el) = container
.children()
.item(idx as u32)
.and_then(|c| c.dyn_into::<web_sys::HtmlElement>().ok())
{
let _ = el.focus();
}
}
/// The selection after extending from the anchor to `idx`, in listing order.
///
/// The anchor is the first selected entry. Every path that replaces the
/// selection puts the new entry first, so the next Shift+click or
/// Shift+arrow extends from the same place.
///
/// `None` when the anchor left the listing. The caller then leaves the
/// selection alone rather than guessing a new one.
fn range_selection(
entries: &[Entry],
cur: Vec<Entry>,
idx: usize,
add: bool,
) -> Option<Vec<Entry>> {
let anchor = match cur.first() {
Some(a) => entries.iter().position(|e| e.name == a.name)?,
None => idx,
};
let (lo, hi) = if anchor <= idx {
(anchor, idx)
} else {
(idx, anchor)
};
let mut next: Vec<Entry> = if add { cur } else { Vec::new() };
let mut have: HashSet<String> = next.iter().map(|s| s.name.clone()).collect();
{
let mut push = |e: &Entry| {
if have.insert(e.name.clone()) {
next.push(e.clone());
}
};
push(&entries[anchor]);
entries[lo..=hi].iter().for_each(&mut push);
}
Some(next)
}
/// Left-click behaviour. Modifier clicks always select:
/// - Ctrl/Cmd+click toggles the entry;
/// - Shift+click selects the range from the anchor (the first selected
@@ -565,29 +781,9 @@ fn selection_click(
ev.stop_propagation();
let cur = selected.get_untracked();
if ev.shift_key() {
let anchor = match cur.first() {
Some(a) => match entries.iter().position(|e| e.name == a.name) {
Some(a) => a,
None => return,
},
None => idx,
};
let (lo, hi) = if anchor <= idx {
(anchor, idx)
} else {
(idx, anchor)
};
// The anchor stays first so the next Shift+click extends from it.
let mut next: Vec<Entry> = if ctrl { cur } else { Vec::new() };
let mut have: HashSet<String> = next.iter().map(|s| s.name.clone()).collect();
let mut add = |e: &Entry| {
if have.insert(e.name.clone()) {
next.push(e.clone());
}
};
add(&entries[anchor]);
entries[lo..=hi].iter().for_each(&mut add);
set_selected.set(next);
if let Some(next) = range_selection(entries, cur, idx, ctrl) {
set_selected.set(next);
}
return;
}
let mut next = cur;
@@ -690,6 +886,78 @@ fn entry_callbacks(
)
}
/// Arrow keys on a focused entry: move focus, and take the selection with it.
///
/// The grid uses all four keys, the list only up and down. A list row is the
/// full width, so left and right are left to the browser (caret browsing,
/// horizontal scrolling).
///
/// Shift extends from the anchor, exactly as Shift+click does, so the two
/// can be mixed in one selection.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn arrow_key(
ev: &web_sys::KeyboardEvent,
all: &Arc<Listing>,
idx: usize,
grid: bool,
shown: RwSignal<usize>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
) {
// A modifier the handler does not implement belongs to the browser
// (Alt+Left is history back, Ctrl+Arrow jumps words in a text field).
if ev.ctrl_key() || ev.meta_key() || ev.alt_key() {
return;
}
let Some(container) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.and_then(|el| el.parent_element())
else {
return;
};
let cols = if grid { grid_columns(&container) } else { 1 };
let step = match ev.key().as_str() {
"ArrowUp" => -(cols as isize),
"ArrowDown" => cols as isize,
"ArrowLeft" if grid => -1,
"ArrowRight" if grid => 1,
_ => return,
};
// Past this point the key is ours: the page must not scroll under it.
ev.prevent_default();
let total = all.entries.len();
let target = idx as isize + step;
if target < 0 || target as usize >= total {
return;
}
let target = target as usize;
let cur = selected.get_untracked();
let next = if ev.shift_key() {
range_selection(&all.entries, cur, target, false)
} else {
Some(vec![all.entries[target].clone()])
};
if let Some(next) = next {
set_selected.set(next);
}
if target < shown.get_untracked() {
focus_item(&container, target);
return;
}
// Reveal whole pages until the target is rendered. The focus call has
// to wait for that render, hence the animation frame.
shown.update(|n| {
while *n <= target {
*n += FILE_PAGE;
}
});
request_animation_frame(move || focus_item(&container, target));
}
/// Whether a double click opens: not with a modifier held, because that is
/// two selecting clicks (the browser still fires `dblclick` for them).
fn plain_dblclick(ev: &MouseEvent) -> bool {
@@ -742,6 +1010,7 @@ fn entries_view(
all.clone(), i, root_id, loc, is_rw, single_click,
selected, set_selected, set_ctx, open_file,
);
let all_keys = all.clone();
let item_name = name.clone();
let selected_now = move || sel_names.with(|s| s.contains(&item_name));
view! {
@@ -755,7 +1024,9 @@ fn entries_view(
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
open_cb.run(());
return;
}
arrow_key(&ev, &all_keys, i, grid, shown, selected, set_selected);
}
on:contextmenu=on_ctx
title=title
▾Mweb/src/views/share_page.rs
@@ -19,6 +19,9 @@ enum SharePageState {
Loading,
NotFound,
Expired,
/// The share asks for a password. The server withholds even its name
/// until the password is entered, so nothing about it is known yet.
Locked,
Active,
}
@@ -60,46 +63,45 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
let (info, set_info) = signal(Option::<ShareInfo>::None);
let (state, set_state) = signal(SharePageState::Loading);
// Apply a resolved share: it becomes the synthetic "me" every file call
// is scoped to. Shared by the initial resolve and by the unlock form.
let accept = Callback::new(move |i: ShareInfo| {
let mode = if i.writable { Mode::Rw } else { Mode::Ro };
set_me.set(Some(Me {
first_boot: false,
user: Some(UserInfo {
id: i.id,
name: "shared".to_string(),
is_admin: false,
single_click_open: false,
language: None,
}),
roots: vec![RootInfo {
id: i.root_id,
name: i.name.clone(),
path: "/".to_string(),
mode,
}],
allow_writable_shares: false,
}));
set_info.set(Some(i));
set_state.set(SharePageState::Active);
});
// Resolve the share once per token and scope all file API calls to it.
{
let tok = token.clone();
let set_me2 = set_me;
let set_info2 = set_info;
let set_state2 = set_state;
spawn_local(async move {
api::set_share_token(Some(&tok));
match api::resolve_share(&tok).await {
Ok(i) => {
let mode = if i.writable { Mode::Rw } else { Mode::Ro };
let me_val = Me {
first_boot: false,
user: Some(UserInfo {
id: i.id,
name: "shared".to_string(),
is_admin: false,
single_click_open: false,
language: None,
}),
roots: vec![RootInfo {
id: i.root_id,
name: i.name.clone(),
path: "/".to_string(),
mode,
}],
allow_writable_shares: false,
};
set_me2.set(Some(me_val));
set_info2.set(Some(i));
set_state2.set(SharePageState::Active);
}
Err(e) => {
let expired = e.status() == Some(410);
set_state2.set(if expired {
SharePageState::Expired
} else {
SharePageState::NotFound
});
}
Ok(i) => accept.run(i),
// 401 means password protected, not missing or gone.
Err(e) => set_state2.set(match e.status() {
Some(410) => SharePageState::Expired,
Some(401) => SharePageState::Locked,
_ => SharePageState::NotFound,
}),
}
});
on_cleanup(move || api::set_share_token(None));
@@ -110,6 +112,8 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
// closing a file share re-opens the file itself (there is nothing to
// close back to, so the close button and Escape stay disabled as
// well).
let token_for_form = token.clone();
let ws = FileWorkspace::new(
me,
loc,
@@ -185,6 +189,11 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
}
.into_view()
.into_any(),
SharePageState::Locked => view! {
<UnlockForm token=token_for_form.clone() accept=accept/>
}
.into_view()
.into_any(),
SharePageState::Active => {
let Some(i) = info.get() else {
return {
@@ -233,3 +242,77 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
</div>
}
}
/// The password gate of a protected share. The page knows nothing about the
/// share yet, so it shows the prompt alone.
#[component]
fn UnlockForm(token: String, accept: Callback<ShareInfo>) -> impl IntoView {
let (password, set_password) = signal(String::new());
let (error, set_error) = signal(Option::<String>::None);
let (busy, set_busy) = signal(false);
// A Callback, not a plain closure: both the button and Enter need it,
// and a closure would be moved into whichever handler came first.
let submit = Callback::new(move |_: ()| {
let pw = password.get();
if pw.is_empty() || busy.get() {
return;
}
set_busy.set(true);
set_error.set(None);
let tok = token.clone();
spawn_local(async move {
match api::unlock_share(&tok, &pw).await {
Ok(i) => accept.run(i),
Err(e) => {
set_error.set(Some(e.to_string()));
set_busy.set(false);
}
}
});
});
view! {
<div class="center-screen">
<div class="card unlock-card">
<h2>{i18n::tr(i18n::k::SHARE_LOCKED_TITLE)}</h2>
<p class="muted">{i18n::tr(i18n::k::ERR_SHARE_LOCKED)}</p>
<input
type="password"
autocomplete="current-password"
autofocus=true
value=move || password.get()
on:input=move |ev: web_sys::Event| {
use wasm_bindgen::JsCast;
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_password.set(t.value());
set_error.set(None);
}
}
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" {
ev.prevent_default();
submit.run(());
}
}
/>
{move || {
let Some(e) = error.get() else {
return view! {}.into_any();
};
view! { <p class="dialog-error">{e}</p> }.into_view().into_any()
}}
<button
class="btn btn-primary unlock-btn"
disabled=move || busy.get() || password.get().is_empty()
on:click=move |_| submit.run(())
>
{i18n::tr(i18n::k::UNLOCK)}
</button>
</div>
</div>
}
}
▾Mweb/src/views/shares.rs
@@ -86,6 +86,7 @@ pub fn ShareDialog(
let (writable, set_writable) = signal(false);
let (choice, set_choice) = signal("never".to_string());
let (custom, set_custom) = signal(String::new());
let (password, set_password) = signal(String::new());
let (created, set_created) = signal(Option::<ShareInfo>::None);
let (busy, set_busy) = signal(false);
let name_msg = name.clone();
@@ -99,8 +100,11 @@ pub fn ShareDialog(
let wr = writable.get();
let rid = root_id;
let p = path.clone();
// An empty field means "no password", not a password of "".
let pw = password.get().trim().to_string();
let pw = (!pw.is_empty()).then_some(pw);
spawn_local(async move {
match api::create_share(rid, &p, wr, exp.as_deref()).await {
match api::create_share(rid, &p, wr, exp.as_deref(), pw.as_deref()).await {
Ok(info) => set_created.set(Some(info)),
Err(e) => {
show(
@@ -149,6 +153,11 @@ pub fn ShareDialog(
None => i18n::t(i18n::k::NEVER_EXPIRES).to_string(),
}}
</p>
{info.has_password.then(|| view! {
<p class="muted share-expiry-note">
{i18n::tr(i18n::k::SHARE_PROTECTED)}
</p>
})}
<div class="modal-actions">
<button class="btn btn-primary" on:click=move |_| close.run(())>{i18n::t(i18n::k::DONE)}</button>
</div>
@@ -236,6 +245,24 @@ pub fn ShareDialog(
} else {
view! {}.into_any()
}}
<div class="share-pw-row">
<label for="share-pw">{i18n::tr(i18n::k::PASSWORD)}</label>
<input
type="password"
id="share-pw"
autocomplete="new-password"
value=move || password.get()
on:input=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_password.set(t.value());
}
}
/>
</div>
<p class="muted share-pw-hint">{i18n::tr(i18n::k::SHARE_PASSWORD_HINT)}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
<button
@@ -413,6 +440,9 @@ pub fn SharesView() -> impl IntoView {
" · {}",
i18n::t_fmt(i18n::k::META_CREATED, &format_date(&s.created_at))
));
if s.has_password {
meta.push_str(&format!(" · {}", i18n::t(i18n::k::SHARE_PROTECTED)));
}
view! {
<div class="share-item">
<div class="share-item-main">
▾Mweb/src/views/shell.rs
@@ -359,7 +359,7 @@ pub fn ShellView(
view! { <UsersView me=me set_me=set_me set_dialog=set_dialog/> }.into_view().into_any()
}
Section::Settings => view! {
<SettingsView me=me set_me=set_me/>
<SettingsView me=me set_me=set_me set_dialog=set_dialog/>
}
.into_view()
.into_any(),