over-engineering cut: -1.7k lines, drop rand, thiserror, uuid, tokio-stream
- server: ServeFile for downloads (2 s Last-Modified guard kept), axum Query, headers::Cookie, merged handlers, helpers and error builders - 304 keeps the file's CSP/XFO; out-of-range mtimes served whole, no panic - web: FileView one struct, ConfirmDialog reused, popover menu, native dialog closedby and form validation, event_target_* helpers, dead CSS removed - i18n for "(you)", admin/disabled badges and user form placeholders Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MCargo.lock
@@ -1053,17 +1053,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1365,6 +1354,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2003,8 +1998,6 @@ dependencies = [
"memchr",
"mime",
"spin",
"tokio",
"tokio-util",
"version_check",
]
@@ -2371,37 +2364,16 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
@@ -2417,9 +2389,6 @@ name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
@@ -2762,6 +2731,7 @@ dependencies = [
"fast_image_resize",
"flate2",
"futures-util",
"getrandom 0.4.3",
"grep",
"headers",
"http-body-util",
@@ -2770,7 +2740,6 @@ dependencies = [
"infer",
"mime_guess",
"multer",
"rand 0.8.8",
"rusqlite",
"rust-embed",
"serde",
@@ -2778,14 +2747,11 @@ dependencies = [
"sha2 0.10.9",
"tar",
"tempfile",
"thiserror 2.0.20",
"tokio",
"tokio-stream",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
"webauthn-rs",
"webauthn-rs-proto",
"webp",
@@ -3226,18 +3192,6 @@ dependencies = [
"syn 3.0.4",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -3306,9 +3260,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@@ -3682,8 +3646,8 @@ dependencies = [
"nom",
"openssl",
"openssl-sys",
"rand 0.9.5",
"rand_chacha 0.9.0",
"rand",
"rand_chacha",
"serde",
"serde_cbor_2",
"serde_json",
Mapi-types/src/lib.rs
@@ -366,8 +366,6 @@ pub enum SearchEvent {
/// search completed.
Done {
stopped: bool,
files: usize,
matches: usize,
/// Files examined (walked) before the stream ended.
scanned: usize,
/// Files skipped for content search (over the size cap).
@@ -392,20 +390,13 @@ pub struct UserInfo {
/// Profile setting: the root the UI opens on page load and on the home
/// link. Always one of `Me::roots` (the server drops a stale id), or
/// None for the root picker.
#[serde(default)]
pub default_root_id: Option<i64>,
/// What this account needs to sign in.
#[serde(default)]
pub auth_mode: AuthMode,
/// Whether a password is set at all. False means passkeys only.
#[serde(default = "yes")]
pub has_password: bool,
}
fn yes() -> bool {
true
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RootInfo {
pub id: i64,
@@ -434,6 +425,7 @@ pub struct Me {
/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
#[derive(Serialize, Deserialize, Clone)]
pub struct ShareInfo {
/// Also the share's synthetic root id in file API calls.
pub id: i64,
pub token: String,
/// Display name (file/folder name, or the root's name for ".").
@@ -446,13 +438,10 @@ pub struct ShareInfo {
pub created_at: String,
/// RFC 3339 UTC expiry; None = never.
pub expires_at: Option<String>,
/// Synthetic root id to use in file API calls.
pub root_id: i64,
/// 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,
}
@@ -488,13 +477,6 @@ pub struct AdminUser {
#[derive(Serialize, Deserialize)]
pub struct OkResp {}
/// DELETE `{FILES}/...`: whether the removed item was a folder (the client
/// reports "folder deleted" vs "file deleted").
#[derive(Serialize, Deserialize)]
pub struct DeleteResp {
pub is_dir: bool,
}
/// `POST ...?action=exists` body: upload targets relative to the request
/// directory (may contain subfolders, like upload part names).
#[derive(Serialize, Deserialize)]
@@ -516,12 +498,6 @@ pub struct ExistsResp {
pub existing: Vec<Existing>,
}
/// Upload success: how many files were written.
#[derive(Serialize, Deserialize)]
pub struct UploadResp {
pub uploaded: usize,
}
/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
#[derive(Serialize, Deserialize)]
pub struct SaveResp {
@@ -748,7 +724,7 @@ pub struct PasskeyRegisterFinish {
}
/// `POST {AUTH_PASSKEY_LOGIN}` — begin a passkey sign-in.
#[derive(Serialize, Deserialize, Default)]
#[derive(Serialize, Deserialize)]
pub struct PasskeyLoginBegin {
/// Account name, when the user typed one. Without it the server issues a
/// discoverable challenge, which only finds passkeys the authenticator
Mserver/Cargo.toml
@@ -39,13 +39,11 @@ webp = "0.3"
# detection we do not need) and makes this a zero-dependency crate.
infer = { version = "0.16", default-features = false, features = ["alloc"] }
mime_guess = "2"
multer = { version = "3", features = ["tokio-io"] }
rand = "0.8"
multer = "3"
rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", features = [
"rt-multi-thread",
"macros",
@@ -61,20 +59,19 @@ flate2 = "1"
zstd = "0.13"
# Deflate only; the defaults add AES, bzip2, lzma, ppmd, zopfli.
zip = { version = "9.0.0-pre3", default-features = false, features = ["deflate-flate2-zlib-rs"] }
tower-http = { version = "0.6", features = ["trace", "set-header"] }
tower-http = { version = "0.6", features = ["trace", "set-header", "fs"] }
# Named in the `DavLockSystem` impl. dav-server does not re-export it, so the
# version has to track dav-server's own.
xmltree = "0.12"
futures-util = "0.3"
# Typed `Authorization: Basic` parsing. Already in the tree via dav-server.
headers = "0.4"
tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ignore = "0.4"
grep = "0.4"
webauthn-rs = { version = "0.5.5", features = ["conditional-ui"], default-features = false }
uuid = { version = "1.26.1", features = ["v4"] }
getrandom = "0.4"
# For `ResidentKeyRequirement` and `AllowCredentials`, which `webauthn-rs` uses
# internally but does not re-export. Keep this version equal to webauthn-rs'
# own: it already pulls this crate in, and two different versions would compile
@@ -86,11 +83,6 @@ base64 = "0.22"
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
bytes = "1"
flate2 = "1"
zstd = "0.13"
tar = "0.4"
zip = { version = "9.0.0-pre3", default-features = false, features = ["deflate-flate2-zlib-rs"] }
[dependencies.rust-embed]
version = "8"
Mserver/src/api/admin.rs
@@ -3,19 +3,17 @@
use std::sync::Arc;
use api_types::{
AdminShare, AdminUser, CreateUser, Mode, OkResp, Root, RootInfo, Settings, UpdateUser,
};
use api_types::{AdminShare, AdminUser, CreateUser, Mode, OkResp, Root, Settings, UpdateUser};
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use crate::api::common::AdminUser as AdminGuard;
use crate::api::common::{
blocking, display_name, hash_password, validate_account_name, validate_password,
blocking, hash_password, root_info, validate_account_name, validate_password,
};
use crate::api::shares;
use crate::db::Db;
use crate::db::RootRow;
use crate::error::{ApiError, AppState};
use crate::fs;
@@ -23,28 +21,14 @@ use crate::fs;
// Helpers
// ---------------------------------------------------------------------------
fn root_info(state: &AppState, r: &crate::db::RootRow) -> RootInfo {
RootInfo {
id: r.id,
name: display_name(state, &r.path),
path: r.path.clone(),
mode: r.mode,
}
}
async fn user_info(
db: &Db,
state: &AppState,
user: &crate::db::User,
) -> Result<AdminUser, ApiError> {
let roots = db.user_roots(user.id).await?;
Ok(AdminUser {
fn admin_user(state: &AppState, user: &crate::db::User, roots: &[RootRow]) -> AdminUser {
AdminUser {
id: user.id,
name: user.name.clone(),
is_admin: user.is_admin,
active: user.active,
roots: roots.iter().map(|r| root_info(state, r)).collect(),
})
}
}
/// Validate each requested root path (must exist, be a directory, and stay
@@ -66,7 +50,11 @@ async fn validate_roots(state: &AppState, roots: &[Root]) -> Result<Vec<(String,
// `FsError`, not the join failure.
blocking(move || {
fs::resolve_root(&server_root, &path2).map_err(|e| {
ApiError::new(StatusCode::BAD_REQUEST, format!("root path '{label}': {e}"))
let msg = ApiError::from(e).1;
ApiError::new(
StatusCode::BAD_REQUEST,
format!("root path '{label}': {msg}"),
)
})
})
.await?;
@@ -89,13 +77,7 @@ pub async fn list_users(
.all_users_with_roots()
.await?
.into_iter()
.map(|(u, roots)| AdminUser {
id: u.id,
name: u.name,
is_admin: u.is_admin,
active: u.active,
roots: roots.iter().map(|r| root_info(&state, r)).collect(),
})
.map(|(u, roots)| admin_user(&state, &u, &roots))
.collect();
Ok(Json(out))
}
@@ -123,7 +105,8 @@ pub async fn create_user(
.db
.create_user(&name, &pass_hash, body.is_admin, &roots)
.await?;
Ok(Json(user_info(&state.db, &state, &user).await?))
let roots = state.db.user_roots(user.id).await?;
Ok(Json(admin_user(&state, &user, &roots)))
}
/// PUT /api/admin/users/{id} — update a user (password / is_admin / active /
@@ -201,7 +184,8 @@ pub async fn update_user(
"err_user_not_found",
)
})?;
Ok(Json(user_info(&state.db, &state, &updated).await?))
let roots = state.db.user_roots(updated.id).await?;
Ok(Json(admin_user(&state, &updated, &roots)))
}
/// DELETE /api/admin/users/{id} — delete a user (not yourself).
Mserver/src/api/app_passwords.rs
@@ -20,25 +20,14 @@ use axum::http::StatusCode;
use crate::api::common::{SessionUser, credential_label};
use crate::auth;
use crate::db::AppPasswordRow;
use crate::error::{ApiError, AppState};
fn info(row: AppPasswordRow) -> AppPasswordInfo {
AppPasswordInfo {
id: row.id,
name: row.name,
created_at: row.created_at,
last_used_at: row.last_used_at,
}
}
/// GET `{AUTH_APP_PASSWORDS}`.
pub async fn list(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
) -> Result<Json<Vec<AppPasswordInfo>>, ApiError> {
let rows = state.db.app_passwords(user.id).await?;
Ok(Json(rows.into_iter().map(info).collect()))
Ok(Json(state.db.app_passwords(user.id).await?))
}
/// POST `{AUTH_APP_PASSWORDS}` — create one and return its secret.
@@ -47,7 +36,7 @@ pub async fn create(
SessionUser { user, .. }: SessionUser,
Json(req): Json<CreateAppPassword>,
) -> Result<Json<NewAppPassword>, ApiError> {
let secret = auth::app_password();
let secret = auth::short_token();
let row = state
.db
.add_app_password(
@@ -64,10 +53,7 @@ pub async fn create(
));
};
tracing::info!(user = %user.name, name = %row.name, "app password created");
Ok(Json(NewAppPassword {
info: info(row),
secret,
}))
Ok(Json(NewAppPassword { info: row, secret }))
}
/// DELETE `{AUTH_APP_PASSWORDS}/{id}`.
Mserver/src/api/auth.rs
@@ -8,8 +8,7 @@ use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use crate::api::common::{
SessionUser, display_name, hash_password, session_auth, validate_account_name,
validate_password,
SessionUser, hash_password, root_info, session_auth, validate_account_name, validate_password,
};
use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie};
use crate::db::{RootRow, User};
@@ -31,7 +30,7 @@ pub async fn me(
roots: Vec::new(),
allow_writable_shares: false,
thumbnails_available: state.thumbs.is_some(),
public_url: state.public_url.clone(),
public_url: public_url(&state),
}));
}
@@ -39,17 +38,18 @@ pub async fn me(
Ok(Json(me_for(&state, &user, roots).await?))
}
/// `--public-url` without the trailing slash `Url` adds: the client appends
/// paths to it.
fn public_url(state: &AppState) -> Option<String> {
state
.public_url
.as_ref()
.map(|u| u.as_str().trim_end_matches('/').to_string())
}
/// Build the `/api/auth/me` payload for an authenticated user.
async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me, ApiError> {
let roots: Vec<RootInfo> = roots
.into_iter()
.map(|r| RootInfo {
id: r.id,
name: display_name(state, &r.path),
path: r.path,
mode: r.mode,
})
.collect();
let roots: Vec<RootInfo> = roots.iter().map(|r| root_info(state, r)).collect();
Ok(Me {
first_boot: false,
@@ -71,7 +71,7 @@ async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me
roots,
allow_writable_shares: state.db.allow_writable_shares().await?,
thumbnails_available: state.thumbs.is_some(),
public_url: state.public_url.clone(),
public_url: public_url(state),
})
}
@@ -130,31 +130,28 @@ pub async fn update_profile(
"err_invalid_language",
));
}
if let Some(Some(id)) = body.default_root_id
&& !roots.iter().any(|r| r.id == id)
{
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"not one of your folders",
"err_invalid_default_root",
));
}
if let Some(v) = body.single_click_open {
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;
}
if let Some(root_id) = body.default_root_id {
if let Some(id) = root_id
&& !roots.iter().any(|r| r.id == id)
{
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"not one of your folders",
"err_invalid_default_root",
));
}
state.db.set_user_default_root(user.id, root_id).await?;
user.default_root_id = root_id;
}
state.db.set_user_profile(&user).await?;
Ok(Json(me_for(&state, &user, roots).await?))
}
@@ -191,12 +188,11 @@ pub async fn setup(
let token = auth::random_token();
state.db.create_session(user.id, &token).await?;
let mut res = Json(OkResp {}).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, state.https).parse().unwrap(),
);
Ok(res)
Ok((
[(header::SET_COOKIE, session_cookie(&token, state.https()))],
Json(OkResp {}),
)
.into_response())
}
/// POST /api/auth/login — the password leg of signing in.
@@ -219,11 +215,7 @@ pub async fn login(
let Some(crate::webauthn::Pending::NeedsPassword { user_id }) =
crate::webauthn::take(state_id)
else {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"that took too long, please try again",
"err_challenge_expired",
));
return Err(crate::api::passkeys::challenge_expired());
};
match state.db.find_user_by_id(user_id).await? {
Some(u) => u.name,
@@ -237,10 +229,7 @@ pub async fn login(
};
// Online guessing gets slower per failed attempt on this name.
let delay = auth::login_delay(&name);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
auth::throttle(&name).await;
let verified = state.db.verify_password(&name, &body.password).await?;
auth::record_login(&name, verified.is_some());
let Some(user) = verified else {
@@ -317,10 +306,9 @@ pub async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> R
if let Some(token) = parse_session_cookie(&headers) {
let _ = state.db.delete_session(&token).await;
}
let mut res = Json(OkResp {}).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
clear_session_cookie(state.https).parse().unwrap(),
);
res
(
[(header::SET_COOKIE, clear_session_cookie(state.https()))],
Json(OkResp {}),
)
.into_response()
}
Mserver/src/api/common.rs
@@ -2,7 +2,6 @@
use std::sync::Arc;
use api_types::P_SHARE;
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;
@@ -24,49 +23,34 @@ pub struct AuthUser {
pub share: Option<ShareRow>,
}
impl<S> FromRequestParts<S> for AuthUser
where
S: HasState + Send + Sync,
{
impl FromRequestParts<Arc<AppState>> for AuthUser {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let state = state.state();
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
// 1. Public share token (`?share=<token>` or `X-Share-Token`). Checked
// first: a request that explicitly carries a share token is a share
// request, even if a session is also present (so a signed-in user
// viewing a share link sees the shared scope).
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(),
mode: share.mode,
}];
Ok(AuthUser {
roots,
share: Some(share),
})
}
Some(_) => Err(ApiError::localized(
StatusCode::GONE,
"this share has expired",
"err_share_expired",
)),
None => Err(ApiError::localized(
StatusCode::NOT_FOUND,
"share not found",
"err_share_not_found",
)),
};
let share = live_share(state, &share_token).await?;
// 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(),
mode: share.mode,
}];
return Ok(AuthUser {
roots,
share: Some(share),
});
}
// 2. Signed-in session. Admins also get the whole server root,
@@ -97,13 +81,13 @@ pub struct SessionUser {
pub roots: Vec<RootRow>,
}
impl<S> FromRequestParts<S> for SessionUser
where
S: HasState + Send + Sync,
{
impl FromRequestParts<Arc<AppState>> for SessionUser {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
if share_token_from_request(parts).is_some() {
return Err(ApiError::localized(
StatusCode::FORBIDDEN,
@@ -111,7 +95,7 @@ where
"err_share_token_forbidden",
));
}
let (user, roots) = session_auth(&parts.headers, state.state()).await?;
let (user, roots) = session_auth(&parts.headers, state).await?;
Ok(SessionUser { user, roots })
}
}
@@ -153,18 +137,39 @@ pub(crate) async fn share_is_locked(
Ok(!state.db.share_unlock_valid(&unlock, share.id).await?)
}
/// A share that exists and has not expired.
pub(crate) async fn live_share(state: &AppState, token: &str) -> Result<ShareRow, ApiError> {
match state.db.share_by_token(token).await? {
Some(share) if !share.is_expired() => Ok(share),
Some(_) => Err(ApiError::localized(
StatusCode::GONE,
"this share has expired",
"err_share_expired",
)),
None => Err(ApiError::localized(
StatusCode::NOT_FOUND,
"share not found",
"err_share_not_found",
)),
}
}
/// The field name is [`api_types::P_SHARE`]; `tests::share_query_field_is_p_share`
/// pins it.
#[derive(serde::Deserialize)]
struct ShareQuery {
share: Option<String>,
}
/// 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> {
if let Some(q) = parts.uri.query() {
for pair in q.split('&') {
if let Some((k, v)) = pair.split_once('=')
&& k == P_SHARE
&& !v.is_empty()
{
return Some(v.to_string());
}
}
let query = axum::extract::Query::<ShareQuery>::try_from_uri(&parts.uri)
.ok()
.and_then(|q| q.0.share)
.filter(|s| !s.is_empty());
if query.is_some() {
return query;
}
parts
.headers
@@ -174,17 +179,6 @@ fn share_token_from_request(parts: &Parts) -> Option<String> {
.map(|s| s.to_string())
}
/// Lets the extractor pull the concrete state type out of a generic `S`.
pub trait HasState {
fn state(&self) -> &AppState;
}
impl HasState for Arc<AppState> {
fn state(&self) -> &AppState {
self
}
}
// ---------------------------------------------------------------------------
// Shared validation / naming helpers
// ---------------------------------------------------------------------------
@@ -201,6 +195,15 @@ pub(crate) fn display_name(state: &AppState, rel: &str) -> String {
.unwrap_or_else(|| rel.to_string())
}
pub(crate) fn root_info(state: &AppState, r: &RootRow) -> api_types::RootInfo {
api_types::RootInfo {
id: r.id,
name: display_name(state, &r.path),
path: r.path.clone(),
mode: r.mode,
}
}
/// A resolved absolute path re-expressed relative to the server root — the
/// form `shares.target` is stored in, so share lookups and share revokes both
/// speak the same spelling of a path.
@@ -241,18 +244,10 @@ where
{
tokio::task::spawn_blocking(f)
.await
.map_err(|_| internal_error())?
.map_err(|_| ApiError::internal())?
.map_err(Into::into)
}
pub(crate) fn internal_error() -> ApiError {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
}
/// Hash a password off the async executor. Argon2 is slow by design, so
/// running it inline would block a tokio worker thread for the whole cost.
pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
@@ -260,13 +255,7 @@ pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
let _slot = crate::auth::ARGON2_SLOTS.acquire().await;
tokio::task::spawn_blocking(move || crate::auth::hash_password(&pw))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?
.map_err(|_| ApiError::internal())?
.map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -301,13 +290,13 @@ pub struct AdminUser {
pub user: User,
}
impl<S> FromRequestParts<S> for AdminUser
where
S: HasState + Send + Sync,
{
impl FromRequestParts<Arc<AppState>> for AdminUser {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let auth = SessionUser::from_request_parts(parts, state).await?;
if !auth.user.is_admin {
return Err(ApiError::localized(
@@ -319,3 +308,26 @@ where
Ok(AdminUser { user: auth.user })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn share_query_field_is_p_share() {
let (mut parts, ()) = axum::http::Request::builder()
.uri(format!("/api/files/1?{}=abc", api_types::P_SHARE))
.body(())
.unwrap()
.into_parts();
assert_eq!(share_token_from_request(&parts).as_deref(), Some("abc"));
// An empty param falls through to the header.
parts.uri = format!("/api/files/1?{}=", api_types::P_SHARE)
.parse()
.unwrap();
parts
.headers
.insert("x-share-token", "def".parse().unwrap());
assert_eq!(share_token_from_request(&parts).as_deref(), Some("def"));
}
}
Mserver/src/api/dav.rs
@@ -99,10 +99,7 @@ pub async fn share(State(state): State<Arc<AppState>>, req: Request<Body>) -> Re
let ok = auth::verify_cached(row.id, "", &password, move || async move {
// Throttled like `POST /api/share/{token}/unlock`, keyed the same
// way, so a mount client is not the cheap way to guess.
let delay = auth::login_delay(&tok);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
auth::throttle(&tok).await;
let ok = auth::verify_password_async(&pw, &hash).await;
auth::record_login(&tok, ok);
ok.then_some(id)
@@ -294,10 +291,7 @@ async fn authenticate(state: &AppState, headers: &HeaderMap) -> Option<(String,
async move {
// The login route's throttle, keyed the same way, so guessing over
// WebDAV is no cheaper than guessing over the login form.
let delay = auth::login_delay(&name);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
auth::throttle(&name).await;
let verified = state.db.verify_password(&name, &password).await.ok()?;
// Record what the password did, not what the rule below decides.
// A mount on an account that requires a passkey keeps retrying,
@@ -719,15 +713,9 @@ impl GuardedFileSystem<Mount> for FbFs {
path: &'a DavPath,
mount: &'a Mount,
) -> FsFuture<'a, Box<dyn DavMetaData>> {
Box::pin(async move {
let (root, rel) = match target(path, mount)? {
Target::Roots => return Ok(Box::new(Meta::synthetic_dir()) as Box<dyn DavMetaData>),
Target::Item { root, rel } => (root, rel),
};
let full = self.resolve(&root, rel, crate::fs::resolve_path).await?;
let meta = blocking(move || std::fs::metadata(&full).map_err(|e| io_error(&e))).await?;
Ok(Box::new(Meta::of(&meta)) as Box<dyn DavMetaData>)
})
Box::pin(self.stat(path, mount, crate::fs::resolve_path, |p| {
std::fs::metadata(p)
}))
}
/// Metadata of the entry itself. `dav-server` asks this before a DELETE,
@@ -738,16 +726,9 @@ impl GuardedFileSystem<Mount> for FbFs {
path: &'a DavPath,
mount: &'a Mount,
) -> FsFuture<'a, Box<dyn DavMetaData>> {
Box::pin(async move {
let (root, rel) = match target(path, mount)? {
Target::Roots => return Ok(Box::new(Meta::synthetic_dir()) as Box<dyn DavMetaData>),
Target::Item { root, rel } => (root, rel),
};
let full = self.resolve(&root, rel, crate::fs::resolve_entry).await?;
let meta = blocking(move || std::fs::symlink_metadata(&full).map_err(|e| io_error(&e)))
.await?;
Ok(Box::new(Meta::of(&meta)) as Box<dyn DavMetaData>)
})
Box::pin(self.stat(path, mount, crate::fs::resolve_entry, |p| {
std::fs::symlink_metadata(p)
}))
}
fn create_dir<'a>(&'a self, path: &'a DavPath, mount: &'a Mount) -> FsFuture<'a, ()> {
@@ -845,6 +826,23 @@ impl GuardedFileSystem<Mount> for FbFs {
}
impl FbFs {
/// Metadata of `path`, resolved by `resolve` and read by `stat`.
async fn stat(
&self,
path: &DavPath,
mount: &Mount,
resolve: fn(&Path, &str, &str) -> Result<PathBuf, crate::fs::FsError>,
stat: fn(&Path) -> std::io::Result<std::fs::Metadata>,
) -> FsResult<Box<dyn DavMetaData>> {
let (root, rel) = match target(path, mount)? {
Target::Roots => return Ok(Box::new(Meta::synthetic_dir())),
Target::Item { root, rel } => (root, rel),
};
let full = self.resolve(&root, rel, resolve).await?;
let meta = blocking(move || stat(&full).map_err(|e| io_error(&e))).await?;
Ok(Box::new(Meta::of(&meta)))
}
/// Run one of the [`crate::fs`] resolvers on the blocking pool.
async fn resolve(
&self,
@@ -993,15 +991,10 @@ impl DavFile for File {
fn write_buf(&mut self, mut buf: Box<dyn Buf + Send>) -> FsFuture<'_, ()> {
Box::pin(async move {
while buf.has_remaining() {
let n = self
.file
.write(buf.chunk())
.await
.map_err(|e| io_error(&e))?;
buf.advance(n);
}
Ok(())
self.file
.write_all_buf(&mut buf)
.await
.map_err(|e| io_error(&e))
})
}
Mserver/src/api/files.rs
@@ -16,14 +16,14 @@ use std::sync::Arc;
use axum::Json;
use axum::extract::{Path as AxumPath, Query as AxumQuery, State};
use axum::http::{StatusCode, header};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use futures_util::StreamExt;
use multer::Multipart;
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tower_http::services::ServeFile;
use crate::api::common::{AuthUser, blocking, target_rel};
use crate::archive::{self, ArchiveFormat};
@@ -31,8 +31,7 @@ use crate::db::{RootRow, ShareRow};
use crate::error::{ApiError, AppState};
use crate::fs::{self, FsError};
use api_types::{
DeleteResp, Existing, ExistsReq, ExistsResp, FilesResp, Mutation, OkResp, Op, P_ACTION,
P_OVERWRITE, SaveResp, SortKey, UploadResp,
Existing, ExistsReq, ExistsResp, FilesResp, Mutation, OkResp, Op, SaveResp, SortKey,
};
/// Upper bound for the in-memory text endpoint (preview, later editor).
@@ -42,74 +41,77 @@ pub(super) const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
// Query params
// ---------------------------------------------------------------------------
/// Query params for `GET /api/files/{root_id}/{*path}`. Without `action` the
/// route lists the directory, and the listing params pick the page;
/// Query params for every file route. Without `action` a `GET` lists the
/// directory, and the listing params pick the page;
/// `?action=download|preview|content` serve the item itself.
///
/// The field names are the shared `P_*` constants.
/// `#[serde(rename)]` only takes a literal, so that link cannot be written
/// here; `tests::query_fields_are_the_shared_constants` pins it instead.
#[derive(Deserialize, Default)]
#[derive(Deserialize)]
pub struct FileQuery {
#[serde(default)]
action: Option<String>,
#[serde(default)]
format: Option<String>,
/// Upload: replace existing files. `true` or `1`.
overwrite: Option<String>,
#[serde(default)]
sort: SortKey,
#[serde(default)]
desc: bool,
#[serde(default)]
offset: usize,
#[serde(default)]
limit: Option<usize>,
#[serde(default)]
dirs: bool,
#[serde(default)]
around: Option<String>,
}
impl FileQuery {
fn overwrite(&self) -> bool {
matches!(self.overwrite.as_deref(), Some("true" | "1"))
}
}
/// Path params of both file routes. The bare `{root_id}` route has no path:
/// it names the root item itself, which for a *file* share is the file.
#[derive(Deserialize)]
pub struct Loc {
root_id: i64,
#[serde(default)]
path: String,
}
// ---------------------------------------------------------------------------
// Listing
// ---------------------------------------------------------------------------
/// GET /api/files/{root_id}/{*path} — list a directory, or serve the item
/// itself via `?action=download|preview|content`.
/// GET — list a directory, or serve the item itself via
/// `?action=download|preview|content|thumb`.
pub async fn file_get(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<(i64, String)>,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
AxumPath(Loc {
root_id,
path: req_rel,
}): AxumPath<Loc>,
AxumQuery(query): AxumQuery<FileQuery>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let (root_id, req_rel) = path.0;
match query.action.as_deref() {
Some(a) if a == api_types::ACTION_DOWNLOAD => {
let range = range_header(&headers);
Some(api_types::ACTION_DOWNLOAD) => {
download(
state,
auth,
root_id,
req_rel,
query.format.as_deref(),
range,
ims_header(&headers),
&headers,
)
.await
}
Some(a) if a == api_types::ACTION_PREVIEW => {
preview(
state,
auth,
root_id,
req_rel,
range_header(&headers),
ims_header(&headers),
)
.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,
Some(api_types::ACTION_PREVIEW) => preview(state, auth, root_id, req_rel, &headers).await,
Some(api_types::ACTION_CONTENT) => content(state, auth, root_id, req_rel).await,
Some(api_types::ACTION_THUMB) => thumb(state, auth, root_id, req_rel).await,
_ => {
let opts = fs::ListOpts {
sort: query.sort,
@@ -117,7 +119,7 @@ pub async fn file_get(
offset: query.offset,
limit: query.limit,
dirs_only: query.dirs,
around: query.around.clone(),
around: query.around,
};
let json = list_inner(state, auth, root_id, req_rel, opts).await?;
Ok(json.into_response())
@@ -125,42 +127,6 @@ pub async fn file_get(
}
}
/// GET /api/files/{root_id} — list the root directory itself, or serve the
/// root item via `?action=download|preview|content` (the root of a *file*
/// share is the file itself).
///
/// Same thing as [`file_get`] with an empty path, so it delegates there.
pub async fn list_root(
state: State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<i64>,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
) -> Result<Response, ApiError> {
file_get(
state,
auth,
AxumPath((path.0, String::new())),
query,
headers,
)
.await
}
fn range_header(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
fn ims_header(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get(header::IF_MODIFIED_SINCE)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
/// Caching policy for a served file.
///
/// `private` because the response depends on who asked. `no-cache`, not
@@ -168,22 +134,11 @@ fn ims_header(headers: &axum::http::HeaderMap) -> Option<String> {
/// instead of re-downloading it.
const FILE_CACHE: &str = "private, no-cache";
/// An mtime (unix seconds) as an HTTP-date, the `Last-Modified` format.
/// None for an unknown mtime (0) and for a file written in the last two
/// seconds: whole-second dates cannot tell two writes in one second apart.
fn http_date(mtime: i64) -> Option<String> {
if mtime <= 0 || mtime + 2 > chrono::Utc::now().timestamp() {
return None;
}
chrono::DateTime::from_timestamp(mtime, 0)
.map(|d| d.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
}
/// True when the client's `If-Modified-Since` is at or after `mtime`.
/// HTTP-dates carry whole seconds, so the comparison is second-precision.
fn unmodified_since(ims: Option<&str>, mtime: i64) -> bool {
chrono::DateTime::parse_from_rfc2822(ims.unwrap_or_default())
.is_ok_and(|t| t.timestamp() >= mtime)
/// Whether an mtime (unix seconds) may be a `Last-Modified` validator. Not an
/// unknown mtime (0), and not one from the last two seconds: whole-second
/// dates cannot tell two writes in one second apart.
fn trusted_mtime(mtime: i64) -> bool {
mtime > 0 && mtime + 2 <= chrono::Utc::now().timestamp()
}
async fn list_inner(
@@ -273,24 +228,14 @@ async fn download(
root_id: i64,
req_rel: String,
format: Option<&str>,
range: Option<String>,
ims: Option<String>,
headers: &HeaderMap,
) -> Result<Response, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let (full, name, is_dir, size, mtime) =
let (full, name, is_dir, _size, mtime) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if !is_dir {
return file_response(
&full,
&name,
size,
false,
range.as_deref(),
mtime,
ims.as_deref(),
)
.await;
return file_response(&full, &name, false, mtime, headers).await;
}
let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
@@ -322,11 +267,10 @@ async fn preview(
auth: AuthUser,
root_id: i64,
req_rel: String,
range: Option<String>,
ims: Option<String>,
headers: &HeaderMap,
) -> Result<Response, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let (full, name, is_dir, size, mtime) =
let (full, name, is_dir, _size, mtime) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if is_dir {
return Err(ApiError::localized(
@@ -335,16 +279,7 @@ async fn preview(
"err_not_a_file",
));
}
file_response(
&full,
&name,
size,
true,
range.as_deref(),
mtime,
ims.as_deref(),
)
.await
file_response(&full, &name, true, mtime, headers).await
}
/// `GET ...?action=content` — raw file bytes for the text preview/editor.
@@ -455,39 +390,18 @@ fn no_thumb() -> ApiError {
/// `X-Expected-Mtime` header is present, the file's current mtime must match
/// it, otherwise `409 Conflict` (the file changed on disk since it was read).
/// Returns the file's new mtime so the client can anchor the next check.
///
/// On the bare root only a *file* share gets past the resolve: for a folder
/// the root is a directory, which is rejected.
pub async fn file_put(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<(i64, String)>,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<SaveResp>, ApiError> {
let (root_id, req_rel) = path.0;
put_inner(state, auth, root_id, req_rel, query, headers, body).await
}
/// `PUT .../{root_id}?action=content` — the root item itself. Only reachable
/// for a *file* share (its root is the file); for folders it resolves to a
/// directory and is rejected below.
pub async fn file_put_root(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<i64>,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<SaveResp>, ApiError> {
put_inner(state, auth, path.0, String::new(), query, headers, body).await
}
async fn put_inner(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
AxumPath(Loc {
root_id,
path: req_rel,
}): AxumPath<Loc>,
AxumQuery(query): AxumQuery<FileQuery>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<SaveResp>, ApiError> {
if query.action.as_deref() != Some(api_types::ACTION_CONTENT) {
@@ -525,166 +439,133 @@ async fn put_inner(
Ok(Json(SaveResp { mtime }))
}
/// Stream a single file to the client with the right disposition.
/// Serve a single file with the right disposition. `ServeFile` answers
/// `Range` (a 206 even for `bytes=0-`, which Firefox needs) and
/// `If-Modified-Since`.
async fn file_response(
full: &std::path::Path,
full: &Path,
name: &str,
size: u64,
inline: bool,
range: Option<&str>,
mtime: i64,
ims: Option<&str>,
headers: &HeaderMap,
) -> Result<Response, ApiError> {
let last_modified = http_date(mtime);
// A revalidating client gets the empty 304 instead of the whole file. The
// policy is repeated on it, so the stored copy does not lose the directive.
if last_modified.is_some() && unmodified_since(ims, mtime) {
return Ok((
StatusCode::NOT_MODIFIED,
[(header::CACHE_CONTROL, FILE_CACHE)],
)
.into_response());
let mut req = axum::http::Request::new(());
if let Some(range) = headers.get(header::RANGE) {
req.headers_mut().insert(header::RANGE, range.clone());
}
let mime = mime_guess::from_path(full)
.first_or_octet_stream()
.to_string();
let disp = disposition(if inline { "inline" } else { "attachment" }, name);
// A single `bytes=a-b` range (media seeking). Anything else is served whole.
let parsed = parse_range(range, size);
let (start, end) = match parsed {
Some(Some(r)) => r,
Some(None) => {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{size}"))
.body(axum::body::Body::empty())
.map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("bad response: {e}"),
)
});
}
None => (0, size),
};
// 206 for every satisfiable `Range`, even one that spans the whole file
// (`bytes=0-`, the first request Firefox makes). Firefox reads a 200 as
// "no range support" and its media cache stops fetching mid-file.
let partial = matches!(parsed, Some(Some(_)));
let body = stream_file(full.to_path_buf(), start, end);
let mut res = Response::builder()
.status(if partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
})
.header(header::CONTENT_DISPOSITION, disp)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, end - start)
// Always sent, validator or not: with no directive a cache may apply
// heuristic freshness to a response that depends on who asked.
.header(header::CACHE_CONTROL, FILE_CACHE);
if let Some(lm) = last_modified {
// The validator the `no-cache` above revalidates against.
res = res.header(header::LAST_MODIFIED, lm);
if trusted_mtime(mtime)
&& let Some(ims) = headers.get(header::IF_MODIFIED_SINCE)
{
req.headers_mut()
.insert(header::IF_MODIFIED_SINCE, ims.clone());
}
if partial {
res = res.header(
header::CONTENT_RANGE,
format!("bytes {start}-{}/{size}", end - 1),
);
// `ServeFile` panics on an mtime it cannot write as an HTTP date: one
// before 1970, or in the year 9999 or later.
let meta = tokio::fs::metadata(full).await.ok();
let dateable = meta
.as_ref()
.and_then(|m| m.modified().ok())
.is_none_or(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.is_ok_and(|d| d.as_secs() < YEAR_9999)
});
let mut res = match meta {
Some(m) if !dateable => whole_file(full.to_path_buf(), m.len()),
_ => ServeFile::new(full)
.try_call(req)
.await
.map_err(|e| {
tracing::warn!(error = %e, path = %full.display(), "download open failed");
ApiError::internal()
})?
.map(axum::body::Body::new),
};
// Judged again on the date actually served: the file may have been
// written since `mtime` was read.
let served = res
.headers()
.get(header::LAST_MODIFIED)
.and_then(|v| v.to_str().ok())
.and_then(|v| chrono::DateTime::parse_from_rfc2822(v).ok());
if !served.is_some_and(|t| trusted_mtime(t.timestamp())) {
res.headers_mut().remove(header::LAST_MODIFIED);
}
// Always sent, validator or not: with no directive a cache may apply
// heuristic freshness to a response that depends on who asked. A 304
// repeats it, so the stored copy does not lose the directive.
res.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static(FILE_CACHE));
// A file the browser would parse as a document (HTML/SVG/XML) is served
// under the sandboxed policy, so it can render as a page without being
// able to act as the app. Derived from the same `mime` we declare.
// Non-scriptable inline files (PDF, …) are frameable by the app itself,
// for the preview modal.
if crate::api::is_scriptable_mime(&mime) {
res = res.header("content-security-policy", crate::api::FILE_CSP);
// able to act as the app. Non-scriptable inline files (PDF, …) are
// frameable by the app itself, for the preview modal. A 304 gets the
// same policy: the browser copies its CSP onto the cached response, and
// the router would otherwise fill in the app policy.
let mime = mime_guess::from_path(full).first_or_octet_stream();
let h = res.headers_mut();
if crate::api::is_scriptable_mime(mime.essence_str()) {
h.insert(
"content-security-policy",
HeaderValue::from_static(crate::api::FILE_CSP),
);
} else if inline {
res = res
.header("content-security-policy", crate::api::INLINE_CSP)
.header(header::X_FRAME_OPTIONS, "SAMEORIGIN");
h.insert(
"content-security-policy",
HeaderValue::from_static(crate::api::INLINE_CSP),
);
h.insert(
header::X_FRAME_OPTIONS,
HeaderValue::from_static("SAMEORIGIN"),
);
}
res.header(header::CONTENT_TYPE, mime)
.body(body)
.map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("bad response: {e}"),
)
})
}
/// Parse a `Range` header against `size`. `None` = serve the whole file,
/// `Some(None)` = unsatisfiable, `Some(Some((start, end)))` = half-open range.
fn parse_range(range: Option<&str>, size: u64) -> Option<Option<(u64, u64)>> {
let spec = range?.strip_prefix("bytes=")?;
// ponytail: one range only; multipart/byteranges is not worth it here.
let (a, b) = spec.split_once('-')?;
let (start, end) = match (a.trim().parse::<u64>().ok(), b.trim().parse::<u64>().ok()) {
(Some(s), Some(e)) => (s, e.saturating_add(1).min(size)),
(Some(s), None) if b.trim().is_empty() => (s, size),
// Suffix form: the last N bytes.
(None, Some(n)) if a.trim().is_empty() => (size.saturating_sub(n), size),
_ => return None,
};
if start >= size || start >= end {
return Some(None);
if res.status().is_success() {
let disp = disposition(if inline { "inline" } else { "attachment" }, name);
res.headers_mut().insert(
header::CONTENT_DISPOSITION,
HeaderValue::try_from(disp).map_err(|_| ApiError::internal())?,
);
}
Some(Some((start, end)))
Ok(res)
}
/// Stream `path[start..end)` to the client in chunks (blocking reader → channel).
fn stream_file(path: std::path::PathBuf, start: u64, end: u64) -> axum::body::Body {
use std::io::Seek;
let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
tokio::task::spawn_blocking(move || {
let mut f = match std::fs::File::open(&path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "download open failed");
return;
}
};
if start > 0 && f.seek(io::SeekFrom::Start(start)).is_err() {
return;
}
let mut left = end - start;
let mut buf = vec![0u8; 256 * 1024];
while left > 0 {
let want = buf.len().min(left as usize);
match f.read(&mut buf[..want]) {
Ok(0) => break,
Ok(n) => {
left -= n as u64;
// Client gone → stop producing.
if tx.blocking_send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "download read failed");
break;
}
}
/// 9999-01-01T00:00:00Z in unix seconds.
const YEAR_9999: u64 = 253_402_300_800;
/// The whole file as a plain 200, with no validator.
// ponytail: no Range support, so a video in such a file cannot seek. Rare
// enough (bogus archive timestamps); serve ranges here if it ever matters.
fn whole_file(path: PathBuf, len: u64) -> Response {
let mime = mime_guess::from_path(&path).first_or_octet_stream();
let body = blocking_body(move |sink| {
if let Err(e) = std::fs::File::open(&path).and_then(|mut f| io::copy(&mut f, sink)) {
tracing::warn!(error = %e, path = %path.display(), "download failed");
}
});
let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
axum::body::Body::from_stream(stream)
(
[
(header::CONTENT_TYPE, mime.to_string()),
(header::CONTENT_LENGTH, len.to_string()),
],
body,
)
.into_response()
}
/// Stream an archive of `dir` (top-level entry `top`) to the client.
fn stream_archive(fmt: ArchiveFormat, dir: std::path::PathBuf, top: String) -> axum::body::Body {
let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
tokio::task::spawn_blocking(move || {
let mut sink = ChanWriter::new(tx);
if let Err(e) = archive::build(fmt, &dir, &top, &mut sink) {
fn stream_archive(fmt: ArchiveFormat, dir: PathBuf, top: String) -> axum::body::Body {
blocking_body(move |sink| {
if let Err(e) = archive::build(fmt, &dir, &top, sink) {
tracing::warn!(error = %e, dir = %dir.display(), "archive build failed");
}
// Dropping the sink flushes its buffer and closes the channel.
});
let stream = ReceiverStream::new(rx).map(Ok::<_, io::Error>);
})
}
/// A response body fed by a blocking writer on the blocking pool.
fn blocking_body(write: impl FnOnce(&mut ChanWriter) + Send + 'static) -> axum::body::Body {
let (tx, mut rx) = mpsc::channel::<Vec<u8>>(16);
// Dropping the writer flushes its buffer and closes the channel.
tokio::task::spawn_blocking(move || write(&mut ChanWriter::new(tx)));
let stream = futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx)).map(Ok::<_, io::Error>);
axum::body::Body::from_stream(stream)
}
@@ -733,43 +614,23 @@ impl Drop for ChanWriter {
// POST dispatch: mkdir | rename/move/copy | upload
// ---------------------------------------------------------------------------
/// POST /api/files/{root_id} — top-level operations (upload into the root
/// directory). The bare root never targets a specific item, so JSON ops with
/// a missing folder name are rejected by the individual handlers.
pub async fn dispatch_root(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<i64>,
headers: axum::http::HeaderMap,
req: axum::http::Request<axum::body::Body>,
) -> Result<Response, ApiError> {
dispatch_inner(state, auth, path.0, String::new(), headers, req).await
}
/// POST /api/files/{root_id}/{*path}
pub async fn dispatch(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<(i64, String)>,
headers: axum::http::HeaderMap,
req: axum::http::Request<axum::body::Body>,
) -> Result<Response, ApiError> {
let (root_id, req_rel) = path.0;
dispatch_inner(state, auth, root_id, req_rel, headers, req).await
}
/// Route one POST to upload, mutation or mkdir.
/// POST — route one request to upload, mutation or mkdir. On the bare root
/// only an upload into the root directory makes sense; the JSON ops reject a
/// missing item name themselves.
///
/// Upload and mutation are recognized by their content type. mkdir carries no
/// body, so it names itself with `?action=mkdir`. Anything else is rejected:
/// an unrecognized content type used to fall through to mkdir, which turned a
/// typo in a header into a silently created folder.
async fn dispatch_inner(
state: Arc<AppState>,
pub async fn dispatch(
State(state): State<Arc<AppState>>,
auth: AuthUser,
root_id: i64,
req_rel: String,
headers: axum::http::HeaderMap,
AxumPath(Loc {
root_id,
path: req_rel,
}): AxumPath<Loc>,
AxumQuery(query): AxumQuery<FileQuery>,
headers: HeaderMap,
req: axum::http::Request<axum::body::Body>,
) -> Result<Response, ApiError> {
let ct = headers
@@ -778,10 +639,12 @@ async fn dispatch_inner(
.unwrap_or("");
if ct.starts_with("multipart/form-data") {
return upload(state, auth, root_id, req_rel, req).await;
return Ok(upload(state, auth, root_id, req_rel, &query, req)
.await?
.into_response());
}
if ct.starts_with("application/json") {
let is_exists = action_param(req.uri()).as_deref() == Some(api_types::ACTION_EXISTS);
let is_exists = query.action.as_deref() == Some(api_types::ACTION_EXISTS);
let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
.await
.map_err(|_| {
@@ -809,7 +672,7 @@ async fn dispatch_inner(
.await?
.into_response());
}
match action_param(req.uri()).as_deref() {
match query.action.as_deref() {
Some(api_types::ACTION_MKDIR) => {
return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
}
@@ -964,9 +827,11 @@ async fn mutation(
pub async fn delete(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<(i64, String)>,
) -> Result<Json<DeleteResp>, ApiError> {
let (root_id, req_rel) = path.0;
AxumPath(Loc {
root_id,
path: req_rel,
}): AxumPath<Loc>,
) -> Result<Json<OkResp>, ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
if req_rel.trim().is_empty() {
return Err(ApiError::localized(
@@ -976,28 +841,37 @@ pub async fn delete(
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
let (is_dir, gone) = blocking(move || fs::remove_item(&server_root, &root_rel, &rel)).await?;
let gone = blocking(move || fs::remove_item(&server_root, &root_rel, &rel)).await?;
revoke_shares_at(&state, &gone).await;
Ok(Json(DeleteResp { is_dir }))
Ok(Json(OkResp {}))
}
// ---------------------------------------------------------------------------
// Upload (multipart)
// ---------------------------------------------------------------------------
fn bad_upload<E>(_: E) -> ApiError {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid upload data",
"err_bad_upload",
)
}
async fn upload(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
query: &FileQuery,
req: axum::http::Request<axum::body::Body>,
) -> Result<Response, ApiError> {
) -> Result<Json<OkResp>, ApiError> {
let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?;
let boundary = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.and_then(parse_boundary)
.and_then(|ct| multer::parse_boundary(ct).ok())
.ok_or_else(|| {
ApiError::localized(
StatusCode::BAD_REQUEST,
@@ -1005,20 +879,14 @@ async fn upload(
"err_bad_multipart",
)
})?;
let overwrite = parse_overwrite(req.uri());
let overwrite = query.overwrite();
let stream = req.into_body().into_data_stream();
let mut multipart = Multipart::new(stream, boundary);
let mut uploaded: usize = 0;
let mut skipped: Vec<String> = Vec::new();
while let Some(mut field) = multipart.next_field().await.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid upload data",
"err_bad_upload",
)
})? {
while let Some(mut field) = multipart.next_field().await.map_err(bad_upload)? {
let part_name = field
.name()
.filter(|n| !n.is_empty())
@@ -1035,37 +903,19 @@ async fn upload(
validate_rel_path(&part_name)?;
let (r, b, rel) = (root_abs.clone(), base.clone(), part_name.clone());
let target = tokio::task::spawn_blocking(move || upload_target(&r, &b, &rel, true))
.await
.map_err(|_| io::Error::other("join"))?
.map_err(target_error)?
let target = blocking(move || upload_target(&r, &b, &rel, true).map_err(target_error))
.await?
.expect("create_parent resolves every parent");
let (exists, is_dir) = (target.exists, target.is_dir);
let target = target.path;
if exists && !overwrite {
// A folder can never be replaced by a file, even with `overwrite`.
// Report it like a conflict so the client fails this one part, not
// the request: a rejected request makes it retry every other file
// alone.
if exists && (!overwrite || is_dir) {
// Drain this part and report it as a conflict at the end.
while let Some(_chunk) = field.chunk().await.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid upload data",
"err_bad_upload",
)
})? {}
skipped.push(part_name);
continue;
}
if exists && is_dir {
// A folder can never be replaced by a file. Report it like a
// conflict so the client fails this one part, not the request:
// a rejected request makes it retry every other file alone.
while let Some(_chunk) = field.chunk().await.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid upload data",
"err_bad_upload",
)
})? {}
while field.chunk().await.map_err(bad_upload)?.is_some() {}
skipped.push(part_name);
continue;
}
@@ -1078,32 +928,19 @@ async fn upload(
.expect("has parent")
.join(format!(".upload-{suffix}")),
);
let tmp_file = tokio::fs::File::create(&tmp.0).await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
let tmp_file = tokio::fs::File::create(&tmp.0).await?;
// Buffered: a multipart chunk is often a few kilobytes, and each
// unbuffered write would be its own syscall.
let mut tmp_file = tokio::io::BufWriter::with_capacity(1 << 20, tmp_file);
let write_failed = loop {
match field.chunk().await.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid upload data",
"err_bad_upload",
)
}) {
Ok(Some(chunk)) => {
match field.chunk().await.map_err(bad_upload)? {
Some(chunk) => {
if let Err(e) = tmp_file.write_all(&chunk).await {
tracing::warn!(error = %e, "write failed during upload");
break true;
}
}
Ok(None) => break tmp_file.flush().await.is_err(),
Err(e) => return Err(e),
None => break tmp_file.flush().await.is_err(),
}
};
if write_failed {
@@ -1113,30 +950,19 @@ async fn upload(
"err_save_failed",
));
}
let tmp2 = tmp.0.clone();
let target2 = target.clone();
// Flatten both errors: the outer `Err` is a panicking or shut-down task,
// the inner one is `publish` refusing. Either way nothing was published.
let published = tokio::task::spawn_blocking(move || publish(&tmp2, &target2, overwrite))
.await
.map_err(io::Error::other)
.and_then(|r| r);
match published {
Ok(Published::Written) => {}
// The target appeared while the body streamed in. The drop
// guard removes the scratch file.
Ok(Published::Exists) => {
skipped.push(part_name);
continue;
}
Err(e) => {
let tmp_path = tmp.0.clone();
let published = blocking(move || {
publish(&tmp_path, &target, overwrite).map_err(|e| {
tracing::warn!(error = %e, path = %target.display(), "publish failed during upload");
return Err(ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
));
}
ApiError::internal()
})
})
.await?;
if let Published::Exists = published {
// The target appeared while the body streamed in. The drop guard
// removes the scratch file.
skipped.push(part_name);
continue;
}
tmp.disarm();
uploaded += 1;
@@ -1157,7 +983,7 @@ async fn upload(
)
.with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })));
}
Ok(Json(UploadResp { uploaded }).into_response())
Ok(Json(OkResp {}))
}
/// The rw root and the upload directory. `root_abs` is the containment
@@ -1277,10 +1103,10 @@ async fn exists(
validate_rel_path(p)?;
}
let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?;
let existing = tokio::task::spawn_blocking(move || {
let existing = blocking(move || {
let mut out = Vec::new();
for path in body.paths {
if let Some(t) = upload_target(&root_abs, &base, &path, false)?
if let Some(t) = upload_target(&root_abs, &base, &path, false).map_err(target_error)?
&& t.exists
{
out.push(Existing {
@@ -1289,11 +1115,9 @@ async fn exists(
});
}
}
Ok::<_, io::Error>(out)
Ok::<_, ApiError>(out)
})
.await
.map_err(|_| io::Error::other("join"))?
.map_err(target_error)?;
.await?;
Ok(Json(ExistsResp { existing }))
}
@@ -1381,31 +1205,6 @@ impl Drop for Scratch {
}
}
fn parse_boundary(content_type: &str) -> Option<String> {
content_type
.split(';')
.map(|s| s.trim())
.find_map(|s| s.strip_prefix("boundary="))
.map(|b| b.trim_matches('"').to_string())
.filter(|b| !b.is_empty())
}
/// Read one query parameter from the request URI.
fn query_param(uri: &axum::http::Uri, key: &str) -> Option<String> {
uri.query()?.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
(k == key).then(|| v.to_string())
})
}
fn action_param(uri: &axum::http::Uri) -> Option<String> {
query_param(uri, P_ACTION)
}
fn parse_overwrite(uri: &axum::http::Uri) -> bool {
matches!(query_param(uri, P_OVERWRITE).as_deref(), Some("true" | "1"))
}
fn validate_rel_path(name: &str) -> Result<(), ApiError> {
for c in std::path::Path::new(name).components() {
match c {
@@ -1453,7 +1252,7 @@ pub(crate) async fn revoke_shares_at(state: &AppState, abs: &std::path::Path) {
}
}
fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
pub(crate) fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
roots.iter().find(|r| r.id == root_id).ok_or_else(|| {
ApiError::localized(
StatusCode::FORBIDDEN,
@@ -1479,7 +1278,8 @@ fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError
mod tests {
use super::*;
use api_types::{
ACTION_DOWNLOAD, P_AROUND, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_SORT,
ACTION_DOWNLOAD, P_ACTION, P_AROUND, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET,
P_OVERWRITE, P_SORT,
};
use axum::http::Uri;
@@ -1549,9 +1349,8 @@ mod tests {
(SortKey::Size, true, 5, Some(10), true, Some("a.txt"))
);
// The same constants drive the hand-rolled readers on the POST path.
assert_eq!(action_param(&uri).as_deref(), Some(ACTION_DOWNLOAD));
let uri: Uri = format!("/f/1/a.txt?{P_OVERWRITE}=true").parse().unwrap();
assert!(parse_overwrite(&uri));
let uri: Uri = format!("/f/1/a.txt?{P_OVERWRITE}=1").parse().unwrap();
let q: FileQuery = AxumQuery::try_from_uri(&uri).unwrap().0;
assert!(q.overwrite());
}
}
Mserver/src/api/mod.rs
@@ -141,12 +141,19 @@ pub fn router(state: Arc<AppState>) -> Router {
)
.route(&app_password_id, delete(app_passwords::delete))
.route(SEARCH, get(search::search))
.route(&files_root, get(files::list_root).put(files::file_put_root))
.route(&files_item, get(files::file_get))
.route(&files_item, put(files::file_put))
.route(&files_root, post(files::dispatch_root))
.route(&files_item, post(files::dispatch))
.route(&files_item, delete(files::delete))
.route(
&files_root,
get(files::file_get)
.put(files::file_put)
.post(files::dispatch),
)
.route(
&files_item,
get(files::file_get)
.put(files::file_put)
.post(files::dispatch)
.delete(files::delete),
)
.route(SHARES, get(shares::list))
.route(SHARES, post(shares::create))
.route(&shares_id, delete(shares::delete))
Mserver/src/api/passkeys.rs
@@ -28,7 +28,7 @@ use crate::webauthn::{Pending, Rp};
// Errors
// ---------------------------------------------------------------------------
fn challenge_expired() -> ApiError {
pub(crate) fn challenge_expired() -> ApiError {
ApiError::localized(
StatusCode::BAD_REQUEST,
"that took too long, please try again",
@@ -656,16 +656,14 @@ async fn record_use(
pub(crate) async fn sign_in(state: &AppState, user_id: i64) -> Result<Response, ApiError> {
let token = auth::random_token();
state.db.create_session(user_id, &token).await?;
let mut res = Json(LoginResp {
ok: true,
..Default::default()
})
.into_response();
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, state.https).parse().unwrap(),
);
Ok(res)
Ok((
[(header::SET_COOKIE, session_cookie(&token, state.https()))],
Json(LoginResp {
ok: true,
..Default::default()
}),
)
.into_response())
}
/// Ask the authenticator to store the credential itself.
Mserver/src/api/search.rs
@@ -78,13 +78,10 @@ const SEARCH_MAX_LINES_PER_FILE: usize = 500;
pub(super) struct SearchQuery {
q: Option<String>,
/// `name` (default), `content` or `both`.
#[serde(default)]
scope: Option<String>,
/// The root to search; omitted = the caller's first root.
#[serde(default)]
root: Option<i64>,
/// Folder inside the root to start in; omitted = the whole root.
#[serde(default)]
path: Option<String>,
}
@@ -163,16 +160,24 @@ pub(super) async fn search(
)
})?;
let events = search_stream(
q.to_string(),
let (tx, mut rx) = tokio::sync::mpsc::channel::<SearchEvent>(256);
let search = Arc::new(SearchState {
tx,
q: q.to_string(),
words: q.split_whitespace().map(|w| w.to_lowercase()).collect(),
want_name,
want_content,
root,
start_rel,
state.root.clone(),
state.db.search_excludes().await?,
slot,
);
server_root: state.root.clone(),
excludes: state.db.search_excludes().await?,
started: Instant::now(),
scanned: AtomicUsize::new(0),
skipped: AtomicUsize::new(0),
matches: AtomicUsize::new(0),
});
run_search(search, slot);
let events = futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx));
// `Sse` does the `data: <json>\n\n` framing and the content-type and
// cache headers; nginx and friends still need telling not to buffer.
let sse = Sse::new(events.map(|ev| Event::default().json_data(&ev)));
@@ -184,7 +189,10 @@ pub(super) async fn search(
/// the walker threads lock-free.
struct SearchState {
tx: tokio::sync::mpsc::Sender<SearchEvent>,
q: String,
words: Vec<String>,
want_name: bool,
want_content: bool,
root: RootRow,
/// Where the walk starts, relative to the root ("" = the root itself).
start_rel: String,
@@ -197,7 +205,6 @@ struct SearchState {
scanned: AtomicUsize,
/// Files skipped for content search (over the size cap).
skipped: AtomicUsize,
names: AtomicUsize,
matches: AtomicUsize,
}
@@ -209,51 +216,22 @@ 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,
want_content: bool,
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);
let state = Arc::new(SearchState {
tx,
words: q.split_whitespace().map(|w| w.to_lowercase()).collect(),
root,
start_rel,
server_root,
excludes,
started: Instant::now(),
scanned: AtomicUsize::new(0),
skipped: AtomicUsize::new(0),
names: AtomicUsize::new(0),
matches: AtomicUsize::new(0),
});
fn run_search(state: Arc<SearchState>, slot: tokio::sync::OwnedSemaphorePermit) {
std::thread::spawn(move || {
// Released when the walk is done, not when the client stops reading.
let _slot = slot;
walk(&state, &q, want_name, want_content);
walk(&state);
// `stopped`: the receiver went away (client stopped or navigated)
// or the match cap was hit, before the walk finished.
let stopped = state.tx.is_closed()
|| state.matches.load(Ordering::Relaxed) >= SEARCH_MAX_MATCH_EVENTS;
let _ = state.tx.blocking_send(SearchEvent::Done {
stopped,
files: state.names.load(Ordering::Relaxed),
matches: state.matches.load(Ordering::Relaxed),
scanned: state.scanned.load(Ordering::Relaxed),
skipped: state.skipped.load(Ordering::Relaxed),
elapsed_ms: state.started.elapsed().as_millis() as u64,
});
});
tokio_stream::wrappers::ReceiverStream::new(rx)
}
/// A root-relative path re-expressed relative to the server root, the form
@@ -288,7 +266,7 @@ fn name_matches(words: &[String], name: &str) -> bool {
/// Walks the root in parallel, matching each entry against the wanted
/// scopes. The visitor is cloned once per worker thread, and returns
/// [`WalkState::Quit`] to stop the whole walk (client went away).
fn walk(st: &Arc<SearchState>, q: &str, want_name: bool, want_content: bool) {
fn walk(st: &Arc<SearchState>) {
let abs = st.server_root.join(&st.root.path);
let start = abs.join(&st.start_rel);
if !start.is_dir() {
@@ -304,21 +282,14 @@ fn walk(st: &Arc<SearchState>, q: &str, want_name: bool, want_content: bool) {
.run(|| {
let abs = abs.clone();
Box::new(move |result| match result {
Ok(entry) => visit(st, q, want_name, want_content, &entry, &abs),
Ok(entry) => visit(st, &entry, &abs),
Err(_) => WalkState::Continue, // unreadable entry: skip like fd
})
});
}
/// One walked entry: emit a name hit, grep it, or both.
fn visit(
st: &Arc<SearchState>,
q: &str,
want_name: bool,
want_content: bool,
entry: &DirEntry,
abs: &Path,
) -> WalkState {
fn visit(st: &Arc<SearchState>, entry: &DirEntry, abs: &Path) -> WalkState {
if st.tx.is_closed() {
return WalkState::Quit;
}
@@ -350,14 +321,13 @@ fn visit(
}
}
if want_name {
if st.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
// ("e" matching `search-test/` dragged in all 400 files under it),
// which buries the entries the user actually named.
let name = entry.file_name().to_string_lossy().to_lowercase();
if name_matches(&st.words, &name) {
st.names.fetch_add(1, Ordering::Relaxed);
let size = if is_dir {
0
} else {
@@ -381,12 +351,12 @@ fn visit(
}
}
if want_content && entry.file_type().is_some_and(|t| t.is_file()) {
if st.want_content && entry.file_type().is_some_and(|t| t.is_file()) {
if entry.metadata().map(|m| m.len()).unwrap_or(0) > SEARCH_MAX_FILE_BYTES {
st.skipped.fetch_add(1, Ordering::Relaxed);
return WalkState::Continue;
}
search_one_file(q, entry.path(), rel, st);
search_one_file(entry.path(), rel, st);
if st.tx.is_closed() || st.matches.load(Ordering::Relaxed) >= SEARCH_MAX_MATCH_EVENTS {
return WalkState::Quit;
}
@@ -398,10 +368,10 @@ fn visit(
/// rather than shared: `RegexMatcher` is not `Sync`, so it cannot cross the
/// walk's thread boundary; a case-insensitive literal compiles in
/// microseconds, negligible next to the file read it protects.
fn search_one_file(q: &str, path: &Path, rel: String, st: &SearchState) {
fn search_one_file(path: &Path, rel: String, st: &SearchState) {
let matcher = match RegexMatcherBuilder::new()
.case_insensitive(true)
.build_literals(&[q])
.build_literals(&[&st.q])
{
Ok(m) => m,
Err(_) => return,
Mserver/src/api/spa.rs
@@ -1,5 +1,3 @@
use std::borrow::Cow;
use axum::http::Uri;
use axum::http::{HeaderMap, Method, StatusCode, header};
use axum::response::{IntoResponse, Response};
@@ -53,7 +51,7 @@ pub(super) async fn fallback(method: Method, uri: Uri, headers: HeaderMap) -> Re
}
let mut res = (
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
body(bytes),
bytes,
)
.into_response();
if let Some(tag) = etag
@@ -69,18 +67,10 @@ pub(super) async fn fallback(method: Method, uri: Uri, headers: HeaderMap) -> Re
(header::CONTENT_TYPE, mime),
(header::CACHE_CONTROL, "no-cache".to_string()),
],
body(bytes),
bytes,
)
.into_response(),
None => DEV_HINT.into_response(),
},
}
}
/// Asset bytes as a response body, without copying the embedded ones.
fn body(bytes: Cow<'static, [u8]>) -> axum::body::Bytes {
match bytes {
Cow::Borrowed(b) => axum::body::Bytes::from_static(b),
Cow::Owned(v) => axum::body::Bytes::from(v),
}
}
Mserver/src/archive.rs
@@ -152,32 +152,24 @@ fn tar_add<W: Write>(
let meta = std::fs::metadata(abs)?;
header.set_mode(if is_dir { 0o755 } else { 0o644 });
header.set_mtime(mtime_secs(&meta));
let name = if is_dir {
format!("{entry}/")
} else {
entry.to_string()
};
if is_dir {
header.set_entry_type(tar::EntryType::Directory);
header.set_size(0);
tar.append_data(&mut header, name, io::empty())?;
tar.append_data(&mut header, format!("{entry}/"), io::empty())
} else {
header.set_entry_type(tar::EntryType::Regular);
header.set_size(meta.len());
let f = std::fs::File::open(abs)?;
tar.append_data(&mut header, name, f)?;
tar.append_data(&mut header, entry, std::fs::File::open(abs)?)
}
Ok(())
}
/// Write the tar stream into `sink` and hand `sink` back, so a caller that
/// wrapped it in a compressor can finish that compressor.
fn build_tar<W: Write>(dir: &Path, top: &str, sink: W) -> io::Result<W> {
let mut tar = tar::Builder::new(sink);
let mut add = |entry: &str, abs: &Path, is_dir: bool| -> io::Result<()> {
walk(dir, top, &mut |entry: &str, abs: &Path, is_dir: bool| {
tar_add(&mut tar, entry, abs, is_dir)
};
walk(dir, top, &mut add)?;
})?;
tar.finish()?;
tar.into_inner()
}
Mserver/src/assets.rs
@@ -20,11 +20,11 @@ pub(crate) type Asset = (Cow<'static, [u8]>, String, String, Option<String>);
/// Look up an asset by (slash-separated) path.
pub(crate) fn get_asset(path: &str) -> Option<Asset> {
let (bytes, from_disk, etag) = read(path)?;
let (bytes, etag) = read(path)?;
let mime = mime_guess::from_path(path)
.first_or_octet_stream()
.to_string();
let cache = if from_disk || path == "index.html" {
let cache = if etag.is_none() || path == "index.html" {
"no-cache".to_string()
} else {
// Trunk hashes asset file names, so they are safe to cache forever.
@@ -34,18 +34,12 @@ pub(crate) fn get_asset(path: &str) -> Option<Asset> {
}
#[cfg(feature = "embedded")]
fn read(path: &str) -> Option<(Cow<'static, [u8]>, bool, Option<String>)> {
fn read(path: &str) -> Option<(Cow<'static, [u8]>, Option<String>)> {
embedded::Assets::get(path).map(|c| {
// The embedded hash is the file's content hash, so it doubles as a
// strong ETag.
let mut etag = String::with_capacity(2 + 32);
etag.push('"');
for b in c.metadata.sha256_hash().iter().take(16) {
use std::fmt::Write as _;
let _ = write!(etag, "{b:02x}");
}
etag.push('"');
(c.data, false, Some(etag))
let etag = format!("\"{}\"", crate::hex(&c.metadata.sha256_hash()[..16]));
(c.data, Some(etag))
})
}
@@ -70,14 +64,14 @@ fn is_safe_asset_path(path: &str) -> bool {
}
#[cfg(not(feature = "embedded"))]
fn read(path: &str) -> Option<(Cow<'static, [u8]>, bool, Option<String>)> {
fn read(path: &str) -> Option<(Cow<'static, [u8]>, Option<String>)> {
if !is_safe_asset_path(path) {
return None;
}
let p = dev_dist_dir().join(path);
if p.is_file() {
// No ETag from disk: the dev flow wants every reload to be fresh.
std::fs::read(&p).ok().map(|b| (Cow::Owned(b), true, None))
std::fs::read(&p).ok().map(|b| (Cow::Owned(b), None))
} else {
None
}
Mserver/src/auth.rs
@@ -14,7 +14,8 @@ pub const SESSION_MAX_AGE: u64 = 60 * 60 * 24 * 30;
pub(crate) static ARGON2_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
pub fn hash_password(password: &str) -> anyhow::Result<String> {
let salt = SaltString::generate(&mut rand::thread_rng());
let salt = SaltString::encode_b64(&random_bytes::<16>())
.map_err(|e| anyhow::anyhow!("salt encoding failed: {e}"))?;
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("password hashing failed: {e}"))?;
@@ -48,13 +49,9 @@ pub fn random_token() -> String {
hex_token(32)
}
/// 16 random bytes, hex-encoded (32 chars). Used for public share links.
pub fn share_token() -> String {
hex_token(16)
}
/// The secret of an app password: 16 random bytes, hex-encoded.
pub fn app_password() -> String {
/// 16 random bytes, hex-encoded (32 chars). Used for public share links and
/// app password secrets.
pub fn short_token() -> String {
hex_token(16)
}
@@ -64,24 +61,19 @@ pub fn app_password() -> String {
/// applies. A lookup by hash then replaces a per-request verification.
pub fn app_password_hash(secret: &str) -> String {
use sha2::{Digest, Sha256};
hex(&Sha256::digest(secret.as_bytes()))
crate::hex(&Sha256::digest(secret.as_bytes()))
}
fn hex_token(bytes: usize) -> String {
use rand::RngCore;
let mut b = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut b);
hex(&b)
getrandom::fill(&mut b).expect("OS random source");
crate::hex(&b)
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
fn random_bytes<const N: usize>() -> [u8; N] {
let mut b = [0u8; N];
getrandom::fill(&mut b).expect("OS random source");
b
}
/// Failed logins per name within the last [`FAILURE_WINDOW`].
@@ -102,6 +94,14 @@ pub fn login_delay(name: &str) -> Duration {
}
}
/// Wait out [`login_delay`] for `key` before checking a credential.
pub async fn throttle(key: &str) {
let delay = login_delay(key);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
}
pub fn record_login(name: &str, ok: bool) {
let mut map = LOGIN_FAILURES.lock().unwrap_or_else(|e| e.into_inner());
map.retain(|_, (_, at)| at.elapsed() < FAILURE_WINDOW);
@@ -193,12 +193,7 @@ pub fn forget_verified_for(subject: i64) {
/// fresh per process, so a dump of the map alone yields no passwords.
fn cache_key(realm: i64, name: &str, password: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
static PEPPER: LazyLock<[u8; 32]> = LazyLock::new(|| {
use rand::RngCore;
let mut b = [0u8; 32];
rand::thread_rng().fill_bytes(&mut b);
b
});
static PEPPER: LazyLock<[u8; 32]> = LazyLock::new(random_bytes);
let mut h = Sha256::new();
h.update(*PEPPER);
h.update(realm.to_le_bytes());
@@ -217,21 +212,24 @@ pub fn basic_credentials(headers: &axum::http::HeaderMap) -> Option<(String, Str
Some((auth.username().to_string(), auth.password().to_string()))
}
pub fn session_cookie(token: &str, https: bool) -> String {
let mut c =
format!("{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}");
/// `Max-Age` is `None` for a browser-session cookie.
fn cookie(name: &str, value: &str, max_age: Option<u64>, https: bool) -> String {
let mut c = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
if let Some(age) = max_age {
c.push_str(&format!("; Max-Age={age}"));
}
if https {
c.push_str("; Secure");
}
c
}
pub fn session_cookie(token: &str, https: bool) -> String {
cookie(COOKIE_NAME, token, Some(SESSION_MAX_AGE), https)
}
pub fn clear_session_cookie(https: bool) -> String {
let mut c = format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
if https {
c.push_str("; Secure");
}
c
cookie(COOKIE_NAME, "", Some(0), https)
}
/// Cookie name proving that the visitor unlocked share `share_id`.
@@ -246,14 +244,7 @@ pub fn share_cookie_name(share_id: i64) -> String {
/// 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
cookie(&share_cookie_name(share_id), token, None, https)
}
/// Extract the unlock token for `share_id` from the Cookie header.
@@ -266,20 +257,15 @@ 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
/// One cookie's value out of the `Cookie` headers. 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 == name
&& !v.is_empty()
{
return Some(v.to_string());
}
}
None
use headers::HeaderMapExt as _;
let cookies = headers.typed_get::<headers::Cookie>()?;
cookies
.iter()
.find(|&(k, v)| k == name && !v.is_empty())
.map(|(_, v)| v.to_string())
}
#[cfg(test)]
@@ -332,14 +318,14 @@ mod tests {
assert_eq!(t.len(), 64);
assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
let s = share_token();
let s = short_token();
assert_eq!(s.len(), 32);
assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
let mut seen = std::collections::HashSet::new();
for _ in 0..100 {
assert!(seen.insert(random_token()), "session token collision");
assert!(seen.insert(share_token()), "share token collision");
assert!(seen.insert(short_token()), "share token collision");
}
}
Mserver/src/cli.rs
@@ -1,6 +1,8 @@
use std::net::IpAddr;
use std::path::PathBuf;
use clap::Parser;
use webauthn_rs::prelude::Url;
#[derive(Parser, Debug)]
#[command(
@@ -29,7 +31,7 @@ pub struct Cli {
/// Address to bind. Use 0.0.0.0 to expose beyond localhost.
#[arg(long, default_value = "127.0.0.1")]
pub bind: String,
pub bind: IpAddr,
/// Folder for the thumbnail cache. Without it thumbnails are off and
/// the grid shows icons only. Entries not used for a day are swept.
@@ -42,7 +44,7 @@ pub struct Cli {
/// proxy. Without it links use whatever address the browser is connected
/// to, and the server assumes plain HTTP.
#[arg(long, env = "FILEBROWSER_PUBLIC_URL")]
pub public_url: Option<String>,
pub public_url: Option<Url>,
}
#[cfg(test)]
@@ -57,7 +59,7 @@ mod tests {
assert_eq!(c.root, PathBuf::from("/r"));
assert_eq!(c.db, PathBuf::from("/d"));
assert_eq!(c.port, 8080);
assert_eq!(c.bind, "127.0.0.1");
assert_eq!(c.bind.to_string(), "127.0.0.1");
assert_eq!(c.cache, None);
}
@@ -76,7 +78,7 @@ mod tests {
])
.unwrap();
assert_eq!(c.port, 9000);
assert_eq!(c.bind, "0.0.0.0");
assert_eq!(c.bind.to_string(), "0.0.0.0");
}
#[test]
Mserver/src/db.rs
@@ -1,14 +1,16 @@
use std::path::Path;
use std::sync::Arc;
pub use api_types::{AuthMode, Mode};
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
pub use api_types::{AppPasswordInfo, AuthMode, Mode};
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
use webauthn_rs::prelude::Uuid;
const SCHEMA_VERSION: i64 = 11;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
/// SQL adapter for reading a [`Mode`]. A newtype is needed because both the
/// rusqlite traits and `Mode` are foreign to this crate. Writes bind
/// `Mode::as_str` directly.
///
/// The stored strings are unchanged ("rw"/"ro"), so old databases still read.
struct SqlMode(Mode);
@@ -22,12 +24,6 @@ impl FromSql for SqlMode {
}
}
impl ToSql for SqlMode {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(ToSqlOutput::from(self.0.as_str()))
}
}
/// SQL adapter for [`AuthMode`], for the same reason as [`SqlMode`].
struct SqlAuthMode(AuthMode);
@@ -40,12 +36,6 @@ impl FromSql for SqlAuthMode {
}
}
impl ToSql for SqlAuthMode {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(ToSqlOutput::from(self.0.as_str()))
}
}
#[derive(Debug, Clone)]
pub struct User {
pub id: i64,
@@ -113,15 +103,6 @@ pub struct PasskeyRow {
pub passkey: String,
}
/// One app password, without its secret.
#[derive(Debug, Clone)]
pub struct AppPasswordRow {
pub id: i64,
pub name: String,
pub created_at: String,
pub last_used_at: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RootRow {
pub id: i64,
@@ -190,12 +171,6 @@ pub type DbResult<T> = Result<T, rusqlite::Error>;
#[derive(Clone)]
pub struct Db(Arc<tokio::sync::Mutex<Connection>>);
impl std::fmt::Debug for Db {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Db").finish()
}
}
impl Db {
pub async fn open(path: &Path) -> anyhow::Result<Self> {
if let Some(parent) = path.parent()
@@ -214,15 +189,6 @@ impl Db {
Ok(Self(Arc::new(tokio::sync::Mutex::new(conn))))
}
/// Open a fresh in-memory database (used by tests — no temp file needed).
pub async fn open_in_memory() -> anyhow::Result<Self> {
let conn = Connection::open_in_memory()?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", "5000")?;
Self::migrate(&conn)?;
Ok(Self(Arc::new(tokio::sync::Mutex::new(conn))))
}
fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
@@ -377,18 +343,7 @@ impl Db {
params![user_id],
)?;
tx.commit()?;
Ok(Some(User {
id: user_id,
name: name.to_string(),
is_admin: true,
active: true,
single_click: false,
thumbnails: true,
language: None,
default_root_id: None,
auth_mode: AuthMode::Either,
has_password: true,
}))
Ok(Some(new_user(user_id, name, true)))
}
pub async fn verify_password(&self, name: &str, password: &str) -> DbResult<Option<User>> {
@@ -562,56 +517,27 @@ impl Db {
for (path, mode) in roots {
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
params![user_id, path, SqlMode(*mode)],
params![user_id, path, mode.as_str()],
)?;
}
tx.commit()?;
Ok(User {
id: user_id,
name: name.to_string(),
is_admin,
active: true,
single_click: false,
thumbnails: true,
language: None,
default_root_id: None,
auth_mode: AuthMode::Either,
has_password: true,
})
}
pub async fn set_user_single_click(&self, id: i64, single_click: bool) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET single_click = ?1 WHERE id = ?2",
params![single_click as i64, id],
)?;
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_default_root(&self, id: i64, root_id: Option<i64>) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET default_root_id = ?1 WHERE id = ?2",
params![root_id, id],
)?;
Ok(())
Ok(new_user(user_id, name, is_admin))
}
pub async fn set_user_language(&self, id: i64, language: Option<&str>) -> DbResult<()> {
/// Write the profile settings a user edits for themselves.
pub async fn set_user_profile(&self, u: &User) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET language = ?1 WHERE id = ?2",
params![language, id],
"UPDATE users SET single_click = ?1, thumbnails = ?2, language = ?3,
default_root_id = ?4
WHERE id = ?5",
params![
u.single_click,
u.thumbnails,
u.language,
u.default_root_id,
u.id
],
)?;
Ok(())
}
@@ -639,7 +565,7 @@ impl Db {
tx.execute("DELETE FROM app_passwords WHERE user_id = ?1", [id])?;
tx.execute(
"UPDATE users SET auth_mode = ?1 WHERE id = ?2",
params![SqlAuthMode(AuthMode::Either), id],
params![AuthMode::Either.as_str(), id],
)?;
}
if let Some(a) = is_admin {
@@ -659,7 +585,7 @@ impl Db {
for (path, mode) in roots {
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
params![id, path, SqlMode(*mode)],
params![id, path, mode.as_str()],
)?;
}
}
@@ -702,7 +628,7 @@ impl Db {
let tx = c.transaction()?;
tx.execute(
"UPDATE users SET auth_mode = ?1 WHERE id = ?2",
params![SqlAuthMode(mode), id],
params![mode.as_str(), id],
)?;
commit_if_reachable(tx, id)
}
@@ -725,7 +651,7 @@ impl Db {
/// Stable for the lifetime of the account: a discoverable passkey stores
/// this value and hands it back at sign-in, so changing it would orphan
/// every existing passkey.
pub async fn user_webauthn_id(&self, id: i64) -> DbResult<uuid::Uuid> {
pub async fn user_webauthn_id(&self, id: i64) -> DbResult<Uuid> {
let c = self.0.lock().await;
let existing: Option<String> = c
.query_row("SELECT webauthn_id FROM users WHERE id = ?1", [id], |r| {
@@ -733,13 +659,10 @@ impl Db {
})
.optional()?
.flatten();
if let Some(parsed) = existing
.as_deref()
.and_then(|s| uuid::Uuid::parse_str(s).ok())
{
if let Some(parsed) = existing.as_deref().and_then(|s| Uuid::parse_str(s).ok()) {
return Ok(parsed);
}
let fresh = uuid::Uuid::new_v4();
let fresh = Uuid::new_v4();
c.execute(
"UPDATE users SET webauthn_id = ?1 WHERE id = ?2",
params![fresh.to_string(), id],
@@ -748,7 +671,7 @@ impl Db {
}
/// The account a discoverable credential's user handle points at.
pub async fn find_user_by_webauthn_id(&self, wid: &uuid::Uuid) -> DbResult<Option<User>> {
pub async fn find_user_by_webauthn_id(&self, wid: &Uuid) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
&format!("SELECT {USER_COLS} FROM users WHERE webauthn_id = ?1"),
@@ -890,7 +813,7 @@ impl Db {
// ---------- app passwords (WebDAV) ----------
pub async fn app_passwords(&self, user_id: i64) -> DbResult<Vec<AppPasswordRow>> {
pub async fn app_passwords(&self, user_id: i64) -> DbResult<Vec<AppPasswordInfo>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT id, name, created_at, last_used_at
@@ -907,7 +830,7 @@ impl Db {
user_id: i64,
name: &str,
secret_hash: &str,
) -> DbResult<Option<AppPasswordRow>> {
) -> DbResult<Option<AppPasswordInfo>> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
let held: i64 = tx.query_row(
@@ -968,7 +891,7 @@ impl Db {
c.execute(
"UPDATE app_passwords SET last_used_at = ?1
WHERE secret_hash = ?2 AND (last_used_at IS NULL OR last_used_at < ?3)",
params![now(), secret_hash, an_hour_ago()],
params![now(), secret_hash, stamp(chrono::Duration::hours(1))],
)?;
}
Ok(user)
@@ -1003,7 +926,7 @@ impl Db {
creator_id,
target,
is_file as i64,
SqlMode(mode),
mode.as_str(),
now(),
expires_at,
password_hash
@@ -1055,7 +978,7 @@ impl Db {
// gone from every browser. Deleting a share takes its own with it.
let unlocks = c.execute(
"DELETE FROM share_unlocks WHERE created_at < ?1",
[expiry_cutoff()],
[stamp(chrono::Duration::days(UNLOCK_MAX_AGE_DAYS))],
)?;
Ok((shares, unlocks))
}
@@ -1288,9 +1211,25 @@ fn map_passkey(r: &rusqlite::Row) -> DbResult<PasskeyRow> {
})
}
/// A just-created account: the column defaults of `users`.
fn new_user(id: i64, name: &str, is_admin: bool) -> User {
User {
id,
name: name.to_string(),
is_admin,
active: true,
single_click: false,
thumbnails: true,
language: None,
default_root_id: None,
auth_mode: AuthMode::Either,
has_password: true,
}
}
/// Column order matched by the `app_passwords` SELECTs above.
fn map_app_password(r: &rusqlite::Row) -> DbResult<AppPasswordRow> {
Ok(AppPasswordRow {
fn map_app_password(r: &rusqlite::Row) -> DbResult<AppPasswordInfo> {
Ok(AppPasswordInfo {
id: r.get(0)?,
name: r.get(1)?,
created_at: r.get(2)?,
@@ -1340,21 +1279,14 @@ pub async fn sweep_forever(db: Db) {
}
}
/// 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)
}
/// An hour back, in the same format as [`now`]. The format sorts
/// lexicographically, so SQL can compare the two as text.
fn an_hour_ago() -> String {
(chrono::Utc::now() - chrono::Duration::hours(1))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
/// The time `ago` in the past as a stored stamp. The format sorts
/// lexicographically, so SQL can compare stamps as text.
fn stamp(ago: chrono::Duration) -> String {
(chrono::Utc::now() - ago).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
stamp(chrono::Duration::zero())
}
const SCHEMA_V1: &str = r#"
@@ -1406,7 +1338,7 @@ mod tests {
// Most tests use an in-memory DB (the file-based path is still covered
// by `v1_db_migrates_to_v2` and the integration harness' `Db::open`).
async fn mem() -> Db {
Db::open_in_memory().await.unwrap()
Db::open(Path::new(":memory:")).await.unwrap()
}
/// `update_user` is the only way production edits these fields, so the
@@ -1415,11 +1347,6 @@ mod tests {
db.update_user(id, pass, admin, active, None).await.unwrap();
}
/// A timestamp `d` in the past, in the format the stamps use.
fn ago(d: chrono::Duration) -> String {
(chrono::Utc::now() - d).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
/// Backdate a stamp, which production code has no reason to do.
async fn set_last_used(db: &Db, secret_hash: &str, at: &str) {
db.0.lock()
@@ -1452,7 +1379,7 @@ mod tests {
#[tokio::test]
async fn app_passwords_open_one_account_and_stamp_their_use() {
let (db, admin) = db_with_admin().await;
let secret = crate::auth::app_password();
let secret = crate::auth::short_token();
let hash = crate::auth::app_password_hash(&secret);
let row = db
.add_app_password(admin.id, "laptop", &hash)
@@ -1476,7 +1403,7 @@ mod tests {
// Ten minutes back is inside the hour window, and far enough from
// `now()` that a rewrite would show at second resolution.
let inside = ago(chrono::Duration::minutes(10));
let inside = stamp(chrono::Duration::minutes(10));
set_last_used(&db, &hash, &inside).await;
db.user_by_app_password(&hash).await.unwrap().unwrap();
assert_eq!(
@@ -1485,7 +1412,7 @@ mod tests {
"a second use inside the hour wrote the stamp again"
);
let outside = ago(chrono::Duration::hours(2));
let outside = stamp(chrono::Duration::hours(2));
set_last_used(&db, &hash, &outside).await;
db.user_by_app_password(&hash).await.unwrap().unwrap();
assert_ne!(
@@ -1495,7 +1422,7 @@ mod tests {
);
// The raw secret is not the key, and an inactive account does not match.
let other = crate::auth::app_password_hash(&crate::auth::app_password());
let other = crate::auth::app_password_hash(&crate::auth::short_token());
assert!(db.user_by_app_password(&other).await.unwrap().is_none());
assert!(db.user_by_app_password(&secret).await.unwrap().is_none());
edit(&db, admin.id, None, None, Some(false)).await;
@@ -1504,7 +1431,7 @@ mod tests {
assert!(db.user_by_app_password(&hash).await.unwrap().is_some());
for i in 1..APP_PASSWORD_LIMIT {
let h = crate::auth::app_password_hash(&crate::auth::app_password());
let h = crate::auth::app_password_hash(&crate::auth::short_token());
assert!(
db.add_app_password(admin.id, &format!("c{i}"), &h)
.await
@@ -1512,7 +1439,7 @@ mod tests {
.is_some()
);
}
let h = crate::auth::app_password_hash(&crate::auth::app_password());
let h = crate::auth::app_password_hash(&crate::auth::short_token());
assert!(
db.add_app_password(admin.id, "one-too-many", &h)
.await
@@ -1528,7 +1455,7 @@ mod tests {
#[tokio::test]
async fn an_admin_password_reset_revokes_the_app_passwords() {
let (db, admin) = db_with_admin().await;
let hash = crate::auth::app_password_hash(&crate::auth::app_password());
let hash = crate::auth::app_password_hash(&crate::auth::short_token());
db.add_app_password(admin.id, "mount", &hash)
.await
.unwrap()
Mserver/src/error.rs
@@ -13,12 +13,8 @@ pub struct AppState {
pub root: PathBuf,
/// What the UI calls the root folder (`--root-name`, else its file name).
pub root_name: String,
/// Whether we sit behind a TLS-terminating reverse proxy.
/// Whether the browser reaches this server over TLS. Derived from
/// `--public-url`; see [`crate::secure`].
pub https: bool,
/// `--public-url` with any trailing slash removed; `None` when unset.
pub public_url: Option<String>,
/// `--public-url`; `None` when unset.
pub public_url: Option<webauthn_rs::prelude::Url>,
/// Thumbnail cache. `None` when `--cache` is unset, which turns
/// thumbnails off everywhere.
pub thumbs: Option<Arc<crate::thumb::Thumbs>>,
@@ -37,6 +33,20 @@ pub struct ApiError(
pub Option<&'static str>,
);
impl AppState {
/// Whether the browser reaches this server over TLS.
///
/// The process itself only ever speaks plain HTTP, so it cannot observe
/// this; `--public-url` is the operator telling it. The answer decides the
/// `Secure` attribute on cookies and the origin passkeys are bound to, and
/// both must agree with the address in the URL bar.
pub fn https(&self) -> bool {
self.public_url
.as_ref()
.is_some_and(|u| u.scheme() == "https")
}
}
impl ApiError {
pub fn new(status: StatusCode, msg: impl Into<String>) -> Self {
Self(status, msg.into(), None, None)
@@ -48,6 +58,14 @@ impl ApiError {
Self(status, msg.into(), None, Some(code))
}
pub fn internal() -> Self {
Self::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
}
pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
self.2 = Some(extra);
self
@@ -74,22 +92,14 @@ impl IntoResponse for ApiError {
impl From<std::io::Error> for ApiError {
fn from(e: std::io::Error) -> Self {
tracing::error!(error = %e, "io error");
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
ApiError::internal()
}
}
impl From<rusqlite::Error> for ApiError {
fn from(e: rusqlite::Error) -> Self {
tracing::error!(error = %e, "database error");
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
ApiError::internal()
}
}
Mserver/src/fs.rs
@@ -13,41 +13,39 @@ use api_types::{Entry, FileKind, FilesResp, SortKey};
use crate::error::ApiError;
#[derive(Debug, thiserror::Error)]
#[derive(Debug)]
pub enum FsError {
#[error("folder not found")]
NotFound,
#[error("not a folder")]
NotADirectory,
#[error("access denied")]
Forbidden,
#[error("the configured folder no longer exists")]
RootMissing,
#[error("already exists")]
Conflict,
#[error("{0}")]
Invalid(String),
}
impl From<FsError> for ApiError {
fn from(e: FsError) -> Self {
use axum::http::StatusCode as S;
match &e {
match e {
FsError::NotFound => {
ApiError::localized(S::NOT_FOUND, e.to_string(), "err_fs_not_found")
ApiError::localized(S::NOT_FOUND, "folder not found", "err_fs_not_found")
}
FsError::NotADirectory => {
ApiError::localized(S::BAD_REQUEST, e.to_string(), "err_fs_not_a_dir")
ApiError::localized(S::BAD_REQUEST, "not a folder", "err_fs_not_a_dir")
}
FsError::Forbidden => {
ApiError::localized(S::FORBIDDEN, e.to_string(), "err_fs_forbidden")
ApiError::localized(S::FORBIDDEN, "access denied", "err_fs_forbidden")
}
FsError::RootMissing => {
ApiError::localized(S::NOT_FOUND, e.to_string(), "err_fs_root_missing")
FsError::RootMissing => ApiError::localized(
S::NOT_FOUND,
"the configured folder no longer exists",
"err_fs_root_missing",
),
FsError::Conflict => {
ApiError::localized(S::CONFLICT, "already exists", "err_fs_conflict")
}
FsError::Conflict => ApiError::localized(S::CONFLICT, e.to_string(), "err_fs_conflict"),
// Dynamic message (e.g. an invalid name), not a fixed string.
FsError::Invalid(_) => ApiError::new(S::BAD_REQUEST, e.to_string()),
FsError::Invalid(msg) => ApiError::new(S::BAD_REQUEST, msg),
}
}
}
@@ -68,16 +66,11 @@ pub fn resolve_root(server_root: &Path, root_rel: &str) -> Result<PathBuf, FsErr
pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
let root_abs = resolve_root(server_root, root_rel)?;
let req = Path::new(req_rel);
for c in req.components() {
if matches!(c, Component::ParentDir) {
return Err(FsError::Forbidden);
}
if req.components().any(|c| c == Component::ParentDir) {
return Err(FsError::Forbidden);
}
let full = root_abs.join(req);
let full = full.canonicalize().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound,
_ => FsError::Forbidden,
})?;
let full = canonical(&full)?;
ensure_within(&root_abs, &full)?;
Ok(full)
}
@@ -87,10 +80,7 @@ pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result
/// directory root beneath it.
pub fn resolve_file(server_root: &Path, rel: &str) -> Result<PathBuf, FsError> {
let full = server_root.join(rel);
let full = full.canonicalize().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound,
_ => FsError::Forbidden,
})?;
let full = canonical(&full)?;
ensure_within(server_root, &full)?;
Ok(full)
}
@@ -108,8 +98,17 @@ fn entry_exists(p: &Path) -> bool {
std::fs::symlink_metadata(p).is_ok()
}
/// `canonicalize`, with a missing path as [`FsError::NotFound`] and every
/// other failure as [`FsError::Forbidden`].
fn canonical(p: &Path) -> Result<PathBuf, FsError> {
p.canonicalize().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound,
_ => FsError::Forbidden,
})
}
fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
if p == base || p.starts_with(base) {
if is_within_or_eq(base, p) {
Ok(())
} else {
Err(FsError::Forbidden)
@@ -487,10 +486,8 @@ pub(crate) fn resolve_entry(
) -> Result<PathBuf, FsError> {
let root_abs = resolve_root(server_root, root_rel)?;
let req = Path::new(req_rel);
for c in req.components() {
if matches!(c, Component::ParentDir) {
return Err(FsError::Forbidden);
}
if req.components().any(|c| c == Component::ParentDir) {
return Err(FsError::Forbidden);
}
let full = root_abs.join(req);
// The parent must exist and stay inside the root.
@@ -538,23 +535,18 @@ pub fn rename_item(
Ok(from)
}
/// Delete a file or a directory tree. Returns whether it was a directory, and
/// the path that is now gone (so the caller can revoke shares naming it).
pub fn remove_item(
server_root: &Path,
root_rel: &str,
req_rel: &str,
) -> Result<(bool, PathBuf), FsError> {
/// Delete a file or a directory tree. Returns the path that is now gone (so
/// the caller can revoke shares naming it).
pub fn remove_item(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
let full = resolve_entry(server_root, root_rel, req_rel)?;
// A symlink is unlinked, never followed: deleting it must not delete the
// file it names. A dangling link is deletable for the same reason.
let is_dir = entry_is_dir(&full);
if is_dir {
if entry_is_dir(&full) {
std::fs::remove_dir_all(&full).map_err(|e| io_err(e, &full))?;
} else {
std::fs::remove_file(&full).map_err(|e| io_err(e, &full))?;
}
Ok((is_dir, full))
Ok(full)
}
/// Overwrite an existing file's contents (the editor's save path).
@@ -614,7 +606,7 @@ fn io_err(e: std::io::Error, p: &Path) -> FsError {
/// True if `a` is `b` or a descendant of `b` (both canonical).
pub(crate) fn is_within_or_eq(base: &Path, p: &Path) -> bool {
p == base || p.starts_with(base)
p.starts_with(base)
}
/// Move an item (possibly across roots). `dst_dir_rel` is the destination
@@ -1328,12 +1320,10 @@ mod tests {
fn remove_file_and_dir() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
let (is_dir, gone) = remove_item(&root, ".", "file.txt").unwrap();
assert!(!is_dir);
let gone = remove_item(&root, ".", "file.txt").unwrap();
assert_eq!(gone, root.join("file.txt"));
assert!(!root.join("file.txt").exists());
let (is_dir, gone) = remove_item(&root, ".", "docs").unwrap();
assert!(is_dir);
let gone = remove_item(&root, ".", "docs").unwrap();
assert_eq!(gone, root.join("docs"));
assert!(!root.join("docs").exists());
assert!(matches!(
Mserver/src/lib.rs
@@ -4,7 +4,7 @@
//! (via `tower::ServiceExt::oneshot`) without binding a real port. The
//! `filebrowser-ng` binary is a thin wrapper around [`run`].
use std::net::{IpAddr, SocketAddr};
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{Context, bail};
@@ -26,19 +26,6 @@ use crate::cli::Cli;
use crate::db::Db;
use crate::error::AppState;
/// Whether the browser reaches this server over TLS.
///
/// The process itself only ever speaks plain HTTP, so it cannot observe this;
/// `--public-url` is the operator telling it. The answer decides the `Secure`
/// attribute on cookies and the origin passkeys are bound to, and both must
/// agree with the address in the URL bar.
fn secure(public_url: Option<&str>) -> bool {
public_url.is_some_and(|u| {
u.split_once("://")
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"))
})
}
/// Validate the CLI config and build the running state + router without
/// binding the port (so tests can exercise everything up to `serve`).
pub async fn build_app(cli: &Cli) -> anyhow::Result<(axum::Router, SocketAddr)> {
@@ -58,35 +45,28 @@ pub async fn build_app(cli: &Cli) -> anyhow::Result<(axum::Router, SocketAddr)>
.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())
Some(dir) => {
let thumbs = crate::thumb::Thumbs::new(dir.clone())
.await
.with_context(|| format!("cannot use thumbnail cache: {}", dir.display()))?,
)),
.with_context(|| format!("cannot use thumbnail cache: {}", dir.display()))?;
tokio::spawn(crate::thumb::sweep_forever(dir.clone()));
Some(Arc::new(thumbs))
}
None => None,
};
if let Some(dir) = cli.cache.clone() {
tokio::spawn(crate::thumb::sweep_forever(dir));
}
tokio::spawn(crate::db::sweep_forever(db.clone()));
let state = Arc::new(AppState {
db,
root: root.clone(),
root_name,
https: secure(cli.public_url.as_deref()),
public_url: cli
.public_url
.as_deref()
.map(|u| u.trim_end_matches('/').to_string()),
public_url: cli.public_url.clone(),
thumbs,
});
let app = api::router(state).layer(TraceLayer::new_for_http());
let ip: IpAddr = cli.bind.parse().context("invalid --bind address")?;
let addr = SocketAddr::new(ip, cli.port);
Ok((app, addr))
Ok((app, SocketAddr::new(cli.bind, cli.port)))
}
/// Parse the CLI, initialize logging and serve until the process is killed.
@@ -140,6 +120,17 @@ async fn shutdown_signal() {
tracing::info!("shutting down, waiting for in-flight requests");
}
/// Lowercase hex of `bytes`.
pub(crate) fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
/// The root folder's own name; "/" has none, so fall back to the full path.
pub fn root_file_name(root: &std::path::Path) -> String {
root.file_name()
@@ -158,21 +149,12 @@ mod tests {
db: db.to_path_buf(),
root_name: None,
port: 8080,
bind: "127.0.0.1".into(),
bind: [127, 0, 0, 1].into(),
cache: None,
public_url: None,
}
}
#[test]
fn tls_comes_from_the_public_url_scheme() {
assert!(secure(Some("https://files.example.com")));
assert!(secure(Some("HTTPS://files.example.com")));
assert!(!secure(Some("http://files.example.com")));
assert!(!secure(Some("files.example.com")));
assert!(!secure(None));
}
#[tokio::test]
async fn build_app_ok() {
let tmp = tempfile::tempdir().unwrap();
@@ -202,20 +184,12 @@ mod tests {
assert!(build_app(&c).await.is_err());
}
#[tokio::test]
async fn build_app_rejects_bad_bind() {
let tmp = tempfile::tempdir().unwrap();
let mut c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
c.bind = "not-an-ip".into();
assert!(build_app(&c).await.is_err());
}
#[tokio::test]
async fn build_app_custom_port_bind() {
let tmp = tempfile::tempdir().unwrap();
let mut c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
c.port = 9999;
c.bind = "0.0.0.0".into();
c.bind = [0, 0, 0, 0].into();
let (_app, addr) = build_app(&c).await.unwrap();
assert_eq!(addr.to_string(), "0.0.0.0:9999");
}
Mserver/src/thumb.rs
@@ -149,7 +149,7 @@ impl Thumbs {
// 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]);
let hex = crate::hex(&h.finalize()[..16]);
self.dir.join(&hex[..2]).join(format!("{}.webp", &hex[2..]))
}
}
@@ -369,14 +369,6 @@ fn sweep(dir: &Path) -> usize {
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::*;
Mserver/src/webauthn.rs
@@ -7,7 +7,7 @@
//! here, keyed by an opaque handle the client echoes back.
use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use axum::extract::FromRequestParts;
@@ -15,7 +15,6 @@ use axum::http::request::Parts;
use axum::http::{HeaderMap, StatusCode, Uri, header};
use webauthn_rs::prelude::*;
use crate::api::common::HasState;
use crate::error::{ApiError, AppState};
/// The relying party, as an extractor.
@@ -26,14 +25,14 @@ use crate::error::{ApiError, AppState};
/// this; set `--public-url` there.
pub struct Rp(pub Webauthn);
impl<S> FromRequestParts<S> for Rp
where
S: HasState + Send + Sync,
{
impl FromRequestParts<Arc<AppState>> for Rp {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
relying_party(state.state(), &parts.uri, &parts.headers).map(Rp)
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
relying_party(state, &parts.uri, &parts.headers).map(Rp)
}
}
@@ -67,7 +66,7 @@ pub fn relying_party(
/// them would break passkeys behind an h2 reverse proxy.
fn origin(state: &AppState, uri: &Uri, headers: &HeaderMap) -> Option<Url> {
if let Some(public) = &state.public_url {
return Url::parse(public).ok();
return Some(public.clone());
}
let host = match uri.authority() {
Some(a) => a.as_str().to_string(),
Mserver/tests/api_auth.rs
@@ -203,7 +203,6 @@ async fn root_name_is_configurable() {
db: env.state.db.clone(),
root: env.state.root.clone(),
root_name: "Media".to_string(),
https: false,
public_url: None,
thumbs: None,
});
@@ -228,8 +227,7 @@ async fn public_url_reaches_client() {
db: env.state.db.clone(),
root: env.state.root.clone(),
root_name: env.state.root_name.clone(),
https: false,
public_url: Some("https://files.example.com".to_string()),
public_url: Some("https://files.example.com".parse().unwrap()),
thumbs: None,
});
env.app = server::api::router(state.clone());
Mserver/tests/api_dav.rs
@@ -478,19 +478,16 @@ async fn share(
writable: bool,
password: Option<&str>,
) -> (String, i64) {
let r = admin
.post_json(
"/api/shares",
&json!({
"root_id": 1,
"path": path,
"writable": writable,
"password": password,
}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text());
let j = r.json();
let j = create_share(
admin,
json!({
"root_id": 1,
"path": path,
"writable": writable,
"password": password,
}),
)
.await;
(
j["token"].as_str().unwrap().to_string(),
j["id"].as_i64().unwrap(),
Mserver/tests/api_files.rs
@@ -205,13 +205,12 @@ async fn download_honours_single_byte_ranges() {
let r = get("bytes=62-999").await;
assert_eq!(r.header("content-range").as_deref(), Some("bytes 62-63/64"));
// Out of range → 416 with the size; garbage → the whole file.
let r = get("bytes=64-70").await;
assert_eq!(r.status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(r.header("content-range").as_deref(), Some("bytes */64"));
let r = get("items=1-2").await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.body.len(), 64);
// Out of range, several ranges, or garbage → 416 with the size.
for range in ["bytes=64-70", "bytes=0-1,4-5", "items=1-2"] {
let r = get(range).await;
assert_eq!(r.status, StatusCode::RANGE_NOT_SATISFIABLE, "{range}");
assert_eq!(r.header("content-range").as_deref(), Some("bytes */64"));
}
}
#[tokio::test]
@@ -631,11 +630,10 @@ async fn delete_file_and_folder() {
let r = admin.delete(&root_path("editme.txt")).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["is_dir"], false);
assert!(!env.file("editme.txt").exists());
let r = admin.delete(&root_path("docs")).await;
assert_eq!(r.json()["is_dir"], true);
assert_eq!(r.status, StatusCode::OK);
assert!(!env.file("docs").exists());
// Missing → 404. A DELETE on the bare root matches no route's method →
@@ -669,7 +667,6 @@ async fn upload_creates_files_and_folders() {
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["uploaded"], 2);
assert_eq!(
std::fs::read(env.file("docs/uploaded.txt")).unwrap(),
b"up1"
@@ -1336,6 +1333,18 @@ async fn download_revalidates_with_last_modified() {
let r = admin.get(&path).await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.header("last-modified").is_none());
// Nor a 304, whatever date the client sends: a second write in the same
// second would go unseen.
let r = admin
.raw(
axum::http::Method::GET,
&path,
&[("if-modified-since", "Fri, 01 Jan 2100 00:00:00 GMT")],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.body, b"v1");
// No validator here, so the policy matters more: with no Cache-Control a
// shared cache may apply heuristic freshness.
assert_eq!(
@@ -1375,6 +1384,78 @@ async fn download_revalidates_with_last_modified() {
);
}
/// An mtime before 1970 has no HTTP date. The file is still served, whole
/// and without a validator.
#[tokio::test]
async fn file_dated_before_1970_is_served() {
let env = Env::new().await;
let admin = env.admin().await;
std::fs::File::options()
.write(true)
.open(env.file("editme.txt"))
.unwrap()
.set_modified(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400))
.unwrap();
for action in ["download", "preview"] {
let r = admin
.raw(
axum::http::Method::GET,
&format!("{}?action={action}", root_path("editme.txt")),
&[("range", "bytes=0-0")],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{action}");
assert_eq!(r.body, b"v1", "{action}");
assert!(r.header("last-modified").is_none(), "{action}");
assert_eq!(
r.header("cache-control").as_deref(),
Some("private, no-cache")
);
}
}
/// The browser copies a 304's CSP onto the cached response, so a revalidated
/// file must keep the policy its 200 had, not get the app's.
#[tokio::test]
async fn revalidation_keeps_the_file_policy() {
let env = Env::new().await;
let admin = env.admin().await;
std::fs::write(env.file("page.html"), "<!doctype html><p>hi").unwrap();
for name in ["page.html", "blob.bin"] {
std::fs::File::options()
.write(true)
.open(env.file(name))
.unwrap()
.set_modified(
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
)
.unwrap();
let path = format!("{}?action=preview", root_path(name));
let first = admin.get(&path).await;
let lm = first.header("last-modified").expect("Last-Modified header");
let r = admin
.raw(
axum::http::Method::GET,
&path,
&[("if-modified-since", lm.as_str())],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::NOT_MODIFIED, "{name}");
assert_eq!(
r.header("content-security-policy"),
first.header("content-security-policy"),
"{name}"
);
assert_eq!(
r.header("x-frame-options"),
first.header("x-frame-options"),
"{name}"
);
}
}
// ---------------------------------------------------------------------------
// Symlinks: an operation on a name acts on the entry, not on what it points at
// ---------------------------------------------------------------------------
Mserver/tests/api_thumbs.rs
@@ -348,7 +348,7 @@ async fn a_share_scopes_thumbnails_to_the_shared_folder() {
.await
.json();
let token = s["token"].as_str().unwrap();
let root_id = s["root_id"].as_i64().unwrap();
let root_id = s["id"].as_i64().unwrap();
let anon = Client::new(env.app.clone());
// A file in the shared folder gets a thumbnail without signing in.
Mserver/tests/common/mod.rs
@@ -73,7 +73,7 @@ impl Env {
std::fs::write(p.join("blob.bin"), (0..64u8).collect::<Vec<_>>()).unwrap();
// In-memory SQLite: no temp files, no WAL, faster than file-backed.
let db = Db::open_in_memory().await.unwrap();
let db = Db::open(std::path::Path::new(":memory:")).await.unwrap();
let cache = match thumbs {
true => Some(tempfile::tempdir().unwrap()),
false => None,
@@ -90,7 +90,6 @@ impl Env {
db,
root: p.canonicalize().unwrap(),
root_name: server::root_file_name(p),
https: false,
public_url: None,
thumbs,
});
@@ -110,17 +109,21 @@ impl Env {
/// Create an admin account (first boot) and return a signed-in client.
pub async fn admin(&self) -> Client {
let c = Client::new(self.app.clone());
let r = c
self.signed_in("/api/auth/setup", "admin", "admin1234")
.await
}
/// POST credentials to `path` and return a client holding the session.
async fn signed_in(&self, path: &str, name: &str, password: &str) -> Client {
let r = Client::new(self.app.clone())
.post_json(
"/api/auth/setup",
&serde_json::json!({ "name": "admin", "password": "admin1234" }),
path,
&serde_json::json!({ "name": name, "password": password }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "setup failed: {}", r.text());
let token = session_cookie(&r).expect("setup must set a session cookie");
assert_eq!(r.status, StatusCode::OK, "{path} as {name}: {}", r.text());
let mut c = Client::new(self.app.clone());
c.set_cookie(&token);
c.set_cookie(&session_cookie(&r).expect("a session cookie"));
c
}
}
@@ -251,16 +254,11 @@ impl Client {
content: &[u8],
expected_mtime: Option<i64>,
) -> Resp {
let mut extra: Vec<(&str, String)> = vec![("content-type", "text/plain".to_string())];
if let Some(m) = expected_mtime {
extra.push(("x-expected-mtime", format!("{m}")));
let mtime = expected_mtime.map(|m| m.to_string());
let mut hdrs = vec![("content-type", "text/plain")];
if let Some(m) = &mtime {
hdrs.push(("x-expected-mtime", m));
}
let owned: Vec<(String, String)> =
extra.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
let hdrs: Vec<(&str, &str)> = owned
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
self.raw(Method::PUT, path, &hdrs, content.to_vec()).await
}
@@ -327,17 +325,14 @@ pub async fn create_user(
/// Log in and return a signed-in client.
pub async fn login(env: &Env, name: &str, password: &str) -> Client {
let c = Client::new(env.app.clone());
let r = c
.post_json(
"/api/auth/login",
&serde_json::json!({ "name": name, "password": password }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "login {name}: {}", r.text());
let mut c = Client::new(env.app.clone());
c.set_cookie(&session_cookie(&r).unwrap());
c
env.signed_in("/api/auth/login", name, password).await
}
/// POST /api/shares and return the created share.
pub async fn create_share(who: &Client, body: serde_json::Value) -> serde_json::Value {
let r = who.post_json("/api/shares", &body).await;
assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text());
r.json()
}
/// Find a user id by name via the admin API.
@@ -349,11 +344,7 @@ pub async fn user_id(admin: &Client, name: &str) -> i64 {
users
.iter()
.find(|u| u["name"] == name)
.unwrap_or_else(|| panic!("user {name} not found"))
.as_object()
.unwrap()
.get("id")
.unwrap()
.unwrap_or_else(|| panic!("user {name} not found"))["id"]
.as_i64()
.unwrap()
}
Mweb/Cargo.toml
@@ -37,7 +37,6 @@ web-sys = { version = "0.3", features = [
"FileSystemDirectoryReader",
"FileSystemEntry",
"FileSystemFileEntry",
"HashChangeEvent",
"Headers",
"HtmlAnchorElement",
"HtmlCollection",
@@ -61,7 +60,6 @@ web-sys = { version = "0.3", features = [
"Response",
"Storage",
"SubmitEvent",
"SvgElement",
"UrlSearchParams",
"Window",
"XmlHttpRequest",
Mweb/app.css
@@ -883,26 +883,18 @@ button:disabled {
color: var(--muted);
}
.sel-more-wrap {
position: relative;
}
.sel-more-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 100;
position: fixed;
inset: auto;
margin: 0;
min-width: 190px;
color: inherit;
background: var(--panel);
border: 1px solid var(--border);
padding: 5px;
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.sel-more-menu.hidden {
display: none;
}
/* selection actions in the top bar */
.topbar .sel-actions {
@@ -2108,84 +2100,6 @@ button:disabled {
white-space: nowrap;
}
.ms {
position: relative;
display: flex;
}
.ms-btn {
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font: inherit;
font-size: 13px;
padding: 0 10px;
cursor: pointer;
max-width: 180px;
}
.ms-btn:hover {
background: var(--bg);
}
.ms-btn > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Square caret drawn with borders (the icon set has no chevron). */
.ms-caret {
flex: none;
width: 8px;
height: 8px;
border-right: 2px solid var(--muted);
border-bottom: 2px solid var(--muted);
transform: rotate(45deg) translate(-1px, -1px);
}
.ms-backdrop {
position: fixed;
inset: 0;
z-index: 40;
}
.ms-drop {
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 41;
min-width: 200px;
max-height: 280px;
overflow: auto;
background: var(--panel);
border: 1px solid var(--border);
padding: 4px 0;
}
.ms-opt {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.ms-opt:hover {
background: var(--bg);
}
.ms-opt span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Run / stop ------------------------------------------------------- */
.btn-run {
Mweb/bun.lock
@@ -17,10 +17,8 @@
"@codemirror/lang-rust": "^6.0.0",
"@codemirror/lang-sql": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/theme-one-dark": "^6.0.0",
"@codemirror/view": "^6.0.0",
"codemirror": "^6.0.0",
},
},
Mweb/cm/wrapper.js
@@ -20,21 +20,23 @@ import { sql } from "@codemirror/lang-sql";
import { java } from "@codemirror/lang-java";
import { php } from "@codemirror/lang-php";
// Map a lowercased file extension to a language id. Unknown → plain text.
// Map a lowercased file extension to a grammar. Unknown → plain text.
const js = () => javascript();
const ts = () => javascript({ typescript: true });
const EXT_TO_LANG = {
js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx",
ts: "typescript", mts: "typescript", cts: "typescript", tsx: "tsx",
json: "json", jsonc: "json",
html: "html", htm: "html",
css: "css", scss: "css", less: "css",
md: "markdown", markdown: "markdown",
py: "python", pyw: "python",
rs: "rust",
c: "cpp", h: "cpp", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", hh: "cpp",
xml: "xml", svg: "xml",
sql: "sql",
java: "java",
php: "php",
js, mjs: js, cjs: js, jsx: () => javascript({ jsx: true }),
ts, mts: ts, cts: ts, tsx: () => javascript({ jsx: true, typescript: true }),
json, jsonc: json,
html, htm: html,
css, scss: css, less: css,
md: markdown, markdown,
py: python, pyw: python,
rs: rust,
c: cpp, h: cpp, cpp, cc: cpp, cxx: cpp, hpp: cpp, hh: cpp,
xml, svg: xml,
sql,
java,
php,
};
// Note: there is deliberately no extension list for "is this text" here. The
@@ -43,36 +45,14 @@ const EXT_TO_LANG = {
// This file only maps an extension to a *grammar*, which content sniffing
// cannot do (a .h is C or C++).
function langFromFilename(filename) {
function langExtension(filename) {
const base = (filename || "").toLowerCase();
const dot = base.lastIndexOf(".");
const ext = dot >= 0 ? base.slice(dot + 1) : base;
return EXT_TO_LANG[ext] || "text";
}
function langExtension(langId) {
switch (langId) {
case "javascript": return javascript();
case "typescript": return javascript({ typescript: true });
case "jsx": return javascript({ jsx: true });
case "tsx": return javascript({ jsx: true, typescript: true });
case "json": return json();
case "html": return html();
case "css": return css();
case "markdown": return markdown();
case "python": return python();
case "rust": return rust();
case "cpp": return cpp();
case "xml": return xml();
case "sql": return sql();
case "java": return java();
case "php": return php();
default: return null;
}
return Object.hasOwn(EXT_TO_LANG, ext) ? EXT_TO_LANG[ext]() : null;
}
function isDark() {
if (typeof window === "undefined") return false;
// The app's forced theme (class on <html>) wins over the OS setting.
const cls = document.documentElement.className;
if (cls.includes("dark")) return true;
@@ -98,13 +78,10 @@ window.__fbng_cm = {
create(container, value, filename, editable, onUpdate) {
const extensions = [appTheme, basicSetup];
if (isDark()) extensions.push(oneDark);
const le = langExtension(langFromFilename(filename));
const le = langExtension(filename);
if (le) extensions.push(le);
if (editable) {
// Ctrl/Cmd-S is intercepted by the host (see Rust keybinding).
extensions.push(EditorState.allowMultipleSelections.of(true));
} else {
if (!editable) {
extensions.push(EditorState.readOnly.of(true), EditorView.editable.of(false));
}
Mweb/index.html
@@ -16,7 +16,7 @@
<body>
<script>
// Apply a forced theme before first paint so light/dark users don't
// flash the opposite palette ("auto" stores nothing, like most sites).
// flash the opposite palette.
try {
var t = localStorage.getItem("fb-theme");
if (t === "light" || t === "dark") document.documentElement.classList.add(t);
Mweb/package.json
@@ -9,8 +9,6 @@
"dependencies": {
"codemirror": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/theme-one-dark": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/lang-json": "^6.0.0",
Mweb/src/api.rs
@@ -88,6 +88,20 @@ struct ErrBody {
skipped: Option<Vec<String>>,
}
impl ErrBody {
/// The error for a non-2xx `status`. Known server errors carry a code
/// the client maps to a localized message; unknown ones fall back to the
/// raw text.
fn into_error(self, status: u16, fallback: &str) -> ApiError {
let fallback = self.error.as_deref().unwrap_or(fallback);
ApiError::Http {
status,
message: crate::i18n::error_text(self.code.as_deref(), fallback),
skipped: self.skipped,
}
}
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
@@ -416,27 +430,8 @@ pub fn rename_item(
)
}
pub fn move_item(
root_id: i64,
path: &str,
dst_root_id: i64,
dst: &str,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
mutation(root_id, path, Op::Move, dst_root_id, dst, overwrite)
}
pub fn copy_item(
root_id: i64,
path: &str,
dst_root_id: i64,
dst: &str,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
mutation(root_id, path, Op::Copy, dst_root_id, dst, overwrite)
}
fn mutation(
/// Move or copy an item into `dst` of `dst_root_id`.
pub fn mutation(
root_id: i64,
path: &str,
op: Op,
@@ -763,15 +758,7 @@ pub fn start_upload(
.flatten()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
let fallback = body
.error
.clone()
.unwrap_or_else(|| "upload failed".to_string());
Err(ApiError::Http {
status,
message: crate::i18n::error_text(body.code.as_deref(), &fallback),
skipped: body.skipped,
})
Err(body.into_error(status, "upload failed"))
};
Ok((req, fut))
}
@@ -1189,13 +1176,14 @@ async fn request<T: DeserializeOwned>(
opts.set_headers_headers(&headers);
}
do_fetch(&url, &opts).await
let resp = fetch_checked(&url, &opts, "request failed").await?;
read_json(&resp).await
}
/// Run one fetch and return the response, turning any non-2xx status into
/// [`ApiError::Http`]. `fallback_msg` is used when the error body has no
/// message. Callers that need the raw response (text, a header, or nothing at
/// all) use this directly; JSON callers go through [`do_fetch`].
/// all) use this directly; JSON callers go through [`request`].
async fn fetch_checked(
url: &str,
opts: &web_sys::RequestInit,
@@ -1219,30 +1207,13 @@ async fn fetch_checked(
let status = resp.status();
if !(200..300).contains(&status) {
let body = parse_error_body(&resp).await;
let fallback = body
.error
.clone()
.unwrap_or_else(|| fallback_msg.to_string());
return Err(ApiError::Http {
status,
// Known server errors carry a code the client maps to a
// localized message; unknown ones fall back to the raw text.
message: crate::i18n::error_text(body.code.as_deref(), &fallback),
skipped: body.skipped,
});
return Err(parse_error_body(&resp)
.await
.into_error(status, fallback_msg));
}
Ok(resp)
}
async fn do_fetch<T: DeserializeOwned>(
url: &str,
opts: &web_sys::RequestInit,
) -> Result<T, ApiError> {
let resp = fetch_checked(url, opts, "request failed").await?;
read_json(&resp).await
}
/// Read a response body as text and parse it with serde_json.
///
/// Going through text rather than `Response::json()` keeps one JSON
Mweb/src/app.rs
@@ -71,7 +71,6 @@ pub fn App() -> impl IntoView {
return view! {
<crate::views::share_page::ShareView token=token loc=loc/>
}
.into_view()
.into_any();
}
match phase.get() {
@@ -80,17 +79,14 @@ pub fn App() -> impl IntoView {
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
</div>
}
.into_view()
.into_any(),
AuthPhase::Setup => view! {
<crate::views::setup::SetupView set_me=set_me set_phase=set_phase/>
}
.into_view()
.into_any(),
AuthPhase::Login => view! {
<crate::views::login::LoginView set_me=set_me set_phase=set_phase/>
}
.into_view()
.into_any(),
AuthPhase::Authed => view! {
<crate::views::shell::ShellView
@@ -102,7 +98,6 @@ pub fn App() -> impl IntoView {
set_theme=set_theme
/>
}
.into_view()
.into_any(),
}
}}
Mweb/src/cm.rs
@@ -20,26 +20,21 @@ extern "C" {
value: &str,
filename: &str,
editable: bool,
on_update: Option<js_sys::Function>,
on_update: Option<&js_sys::Function>,
) -> Result<JsValue, JsValue>;
#[wasm_bindgen(js_namespace = __fbng_cm, js_name = destroy, catch)]
fn js_destroy(view: &JsValue) -> Result<(), JsValue>;
#[wasm_bindgen(js_namespace = __fbng_cm, catch)]
pub fn destroy(view: &JsValue) -> Result<(), JsValue>;
/// Replace the whole document (used when (re)loading a file).
#[wasm_bindgen(js_namespace = __fbng_cm, js_name = setValue, catch)]
fn js_set_value(view: &JsValue, text: &str) -> Result<(), JsValue>;
pub fn set_value(view: &JsValue, text: &str) -> Result<(), JsValue>;
#[wasm_bindgen(js_namespace = __fbng_cm, js_name = getValue, catch)]
fn js_get_value(view: &JsValue) -> Result<JsValue, JsValue>;
#[wasm_bindgen(js_namespace = __fbng_cm, js_name = focus, catch)]
fn js_focus(view: &JsValue) -> Result<(), JsValue>;
}
/// A live CodeMirror editor. Destroy it with [`destroy`] when the host goes
/// away (we do that in a Leptos `on_cleanup`).
pub struct Cm {
pub view: JsValue,
#[wasm_bindgen(js_namespace = __fbng_cm, catch)]
pub fn focus(view: &JsValue) -> Result<(), JsValue>;
}
/// Extract a human-readable message from a JS error value.
@@ -50,39 +45,23 @@ fn err_str(e: JsValue) -> String {
.unwrap_or_else(|| "editor error".to_string())
}
/// Create an editor inside `container`.
/// Create an editor inside `container` and return its view handle. Destroy
/// it with [`destroy`] when the host goes away.
///
/// `on_update` is an optional JS function called whenever the document changes
/// (used to track the editor's dirty state). If provided, the caller must keep
/// the backing `Closure` alive for as long as the editor exists.
/// `on_update` is called whenever the document changes (used to track the
/// editor's dirty state). The caller must keep the backing `Closure` alive
/// for as long as the editor exists.
pub fn create(
container: &web_sys::Element,
value: &str,
filename: &str,
editable: bool,
on_update: Option<&JsValue>,
) -> Result<Cm, String> {
let on_update = on_update.map(|f| f.clone().unchecked_into::<js_sys::Function>());
js_create(container, value, filename, editable, on_update)
.map(|view| Cm { view })
.map_err(err_str)
}
pub fn destroy(view: &JsValue) {
let _ = js_destroy(view);
}
/// Replace the whole document (used when (re)loading a file).
pub fn set_value(view: &JsValue, text: &str) {
let _ = js_set_value(view, text);
on_update: Option<&js_sys::Function>,
) -> Result<JsValue, String> {
js_create(container, value, filename, editable, on_update).map_err(err_str)
}
/// Read the current document text (used by the editor's save path).
pub fn get_value(view: &JsValue) -> Option<String> {
js_get_value(view).ok()?.as_string()
}
/// Focus the editor (so the caret is ready for typing on open).
pub fn focus(view: &JsValue) {
let _ = js_focus(view);
}
Mweb/src/components/modal.rs
@@ -5,8 +5,6 @@
//! top layer, and Escape-to-close. None of that is hand-rolled here.
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::MouseEvent;
#[component]
pub fn Modal(
@@ -35,20 +33,8 @@ pub fn Modal(
// Unmounting an open dialog does not fire this, so the close
// path the owner drives cannot loop back here.
on:close=move |_| on_close.run(())
// A click on the backdrop targets the <dialog> itself; a click
// anywhere inside targets that element instead. The card below
// carries the padding, so the dialog box is never a click
// target except on the backdrop.
on:click=move |ev: MouseEvent| {
let Some(el) = dialog.get() else { return };
let on_backdrop = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Node>().ok())
.is_some_and(|t| el.is_same_node(Some(&t)));
if on_backdrop {
on_close.run(());
}
}
// Light dismiss: a backdrop click closes the dialog like Escape.
closedby="any"
>
<div class=format!("modal {class}")>{children()}</div>
</dialog>
Mweb/src/editor.rs
@@ -8,8 +8,8 @@
//! dirty/saving state through the shared signals.
use leptos::prelude::*;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, ApiError};
@@ -19,25 +19,15 @@ use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show};
use crate::i18n;
use crate::icons::IconName;
/// A text file the user wants to edit.
#[derive(Clone)]
pub struct EditTarget {
pub root_id: i64,
/// Path relative to the root (e.g. "docs/a.txt").
pub path: String,
/// Display name (used for the title + language detection).
pub name: String,
/// True in read-only folders: the file is shown, not editable.
pub readonly: bool,
}
use crate::views::dialogs::ConfirmDialog;
use crate::views::file_view::FileView;
/// Read the current document and PUT it, updating the editor's state.
///
/// `force` skips the server's conflict check (used by the "Overwrite" button).
#[allow(clippy::too_many_arguments)] // long signal/JS-handle list
async fn do_save(
target: EditTarget,
target: FileView,
force: bool,
cm_view: StoredValue<Option<JsValue>, LocalStorage>,
loaded_mtime: StoredValue<Option<i64>>,
@@ -78,7 +68,7 @@ async fn do_save(
/// save and close).
#[component]
pub fn Editor(
target: EditTarget,
target: FileView,
/// The editor stores its save callback here (top-bar Save button);
/// cleared on unmount. Absent in read-only mode.
register_save: WriteSignal<Option<Callback<()>>>,
@@ -115,16 +105,14 @@ pub fn Editor(
// Load the file and create an editable CodeMirror.
{
let t = target.clone();
let st = set_status;
let sd = set_dirty;
node.on_load(move |el: web_sys::HtmlDivElement| {
spawn_local(async move {
match api::fetch_content_meta(t.root_id, &t.path).await {
Ok((text, mtime)) => {
if ro {
match cm::create(&el, &text, &t.name, false, None) {
Ok(c) => cm_view.set_value(Some(c.view.clone())),
Err(e) => st.set(Some(e)),
Ok(v) => cm_view.set_value(Some(v)),
Err(e) => set_status.set(Some(e)),
}
return;
}
@@ -135,24 +123,24 @@ pub fn Editor(
suppress.set_value(false);
return;
}
sd.set(true);
set_dirty.set(true);
});
// Borrow the JS function while `closure` is alive; the
// synchronous create() only needs it for that call.
let js_ref: &JsValue = closure.as_ref();
let js_ref = closure.as_ref().unchecked_ref();
match cm::create(&el, &text, &t.name, true, Some(js_ref)) {
Ok(c) => {
cm::focus(&c.view);
cm_view.set_value(Some(c.view.clone()));
Ok(v) => {
let _ = cm::focus(&v);
cm_view.set_value(Some(v));
cm_change.set_value(Some(closure));
loaded_mtime.set_value(mtime);
}
Err(e) => st.set(Some(e)),
Err(e) => set_status.set(Some(e)),
}
// On failure the closure is simply dropped here; on
// success it is owned by `cm_change` until cleanup.
}
Err(e) => st.set(Some(e.to_string())),
Err(e) => set_status.set(Some(e.to_string())),
}
});
});
@@ -163,7 +151,7 @@ pub fn Editor(
// Rust-side owner of the change-listener closure.
on_cleanup(move || {
if let Some(v) = cm_view.try_update_value(Option::take).flatten() {
cm::destroy(&v);
let _ = cm::destroy(&v);
}
cm_change.try_update_value(Option::take);
});
@@ -171,98 +159,79 @@ pub fn Editor(
// Save and overwrite differ only in the conflict check (`force`).
let make_save = |force: bool| {
let t = target.clone();
let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict);
Callback::new(move |_| {
let t2 = t.clone();
spawn_local(async move {
do_save(
t2,
force,
cm_view,
loaded_mtime,
sd,
ss,
st,
sc,
toast,
refresh,
)
.await;
});
spawn_local(do_save(
t.clone(),
force,
cm_view,
loaded_mtime,
set_dirty,
set_saving,
set_status,
set_conflict,
toast,
refresh,
));
})
};
let save_cb: Callback<()> = make_save(false);
let overwrite_cb: Callback<()> = make_save(true);
// Expose the save callback to the top bar (read-only mode has none).
{
let rs = register_save;
let cb = save_cb;
Effect::new(move |_| {
rs.set(if ro { None } else { Some(cb) });
});
on_cleanup(move || rs.set(None));
}
Effect::new(move |_| {
register_save.set(if ro { None } else { Some(save_cb) });
});
on_cleanup(move || register_save.set(None));
// Load the latest on-disk version (discards local edits).
let load_latest_cb: Callback<()> = {
let t = target.clone();
let (sd, st, sc) = (set_dirty, set_status, set_conflict);
Callback::new(move |_| {
let t2 = t.clone();
let t = t.clone();
spawn_local(async move {
st.set(None);
match api::fetch_content_meta(t2.root_id, &t2.path).await {
set_status.set(None);
match api::fetch_content_meta(t.root_id, &t.path).await {
Ok((text, mtime)) => {
cm_view.with_value(|v| {
if let Some(v) = v.as_ref() {
suppress.set_value(true);
cm::set_value(v, &text);
let _ = cm::set_value(v, &text);
}
});
loaded_mtime.set_value(mtime);
sd.set(false);
sc.set(false);
set_dirty.set(false);
set_conflict.set(false);
}
Err(e) => st.set(Some(e.to_string())),
Err(e) => set_status.set(Some(e.to_string())),
}
});
})
};
// Close, prompting first if there are unsaved changes.
let request_close: Callback<()> = {
let d = dirty;
let scd = set_confirm_discard;
Callback::new(move |_| {
if d.get() {
scd.set(true);
} else {
close.run(());
}
})
};
let request_close = Callback::new(move |_| {
if dirty.get() {
set_confirm_discard.set(true);
} else {
close.run(());
}
});
// Expose the guarded close request to the top bar's X button.
{
let rc = request_close;
let sig = register_close;
Effect::new(move |_| {
sig.set(Some(rc));
});
on_cleanup(move || sig.set(None));
}
Effect::new(move |_| {
register_close.set(Some(request_close));
});
on_cleanup(move || register_close.set(None));
// Ctrl/Cmd+S saves (scoped to the editor's lifetime); nothing to save
// in read-only mode.
if !ro {
let cb = save_cb;
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if (ev.ctrl_key() || ev.meta_key()) && (ev.key() == "s" || ev.key() == "S") {
ev.prevent_default();
cb.run(());
save_cb.run(());
}
},
);
@@ -271,112 +240,71 @@ pub fn Editor(
// Escape: closes the unsaved-changes prompt if open, otherwise the error
// card if shown, otherwise closes the editor (with the dirty guard). The
// conflict dialog is left alone — it requires an explicit choice.
{
let cd = confirm_discard;
let scd = set_confirm_discard;
let cf = conflict;
let st = status;
let stx = set_status;
let rc = request_close;
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape" {
return;
}
if cd.get() {
scd.set(false);
} else if st.get().is_some() {
stx.set(None);
} else if !cf.get() {
rc.run(());
}
},
);
}
crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape" {
return;
}
if confirm_discard.get() {
set_confirm_discard.set(false);
} else if status.get().is_some() {
set_status.set(None);
} else if !conflict.get() {
request_close.run(());
}
});
view! {
<div class="file-editor">
<div node_ref=node class="cm-container cm-edit"></div>
{move || {
let err = status.get();
match err {
Some(e) => view! {
<div class="cm-error">
<div class="cm-error-card">
<button
class="cm-error-close"
title={i18n::tr(i18n::k::DISMISS)}
aria-label={i18n::tr(i18n::k::DISMISS_ERROR)}
on:click=move |_| set_status.set(None)
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
<div class="cm-error-text">{e}</div>
</div>
status.get().map(|e| view! {
<div class="cm-error">
<div class="cm-error-card">
<button
class="cm-error-close"
title={i18n::tr(i18n::k::DISMISS)}
aria-label={i18n::tr(i18n::k::DISMISS_ERROR)}
on:click=move |_| set_status.set(None)
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
<div class="cm-error-text">{e}</div>
</div>
}
.into_view()
.into_any(),
None => {
view! {}.into_any()
},
}
</div>
})
}}
// 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! {
<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>
</Modal>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
<Show when=move || conflict.get()>
<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>
</Modal>
</Show>
// Unsaved-changes confirmation. Dismissing it is "keep editing":
// the destructive choice needs the button.
{move || {
if confirm_discard.get() {
view! {
<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>
</Modal>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
<Show when=move || confirm_discard.get()>
<ConfirmDialog
title=i18n::t(i18n::k::UNSAVED_CHANGES).to_string()
message=i18n::t(i18n::k::DISCARD_QUESTION).to_string()
submit=i18n::t(i18n::k::DISCARD).to_string()
danger=true
cancel=i18n::t(i18n::k::KEEP_EDITING).to_string()
on_submit=close
on_close=Callback::new(move |_| set_confirm_discard.set(false))
/>
</Show>
</div>
}
}
Mweb/src/i18n.rs
@@ -364,7 +364,6 @@ i18n_keys! {
LOADING_SHARE = "loading_share" => "Loading share…",
LOCATION = "location" => "Location",
LOG_OUT = "log_out" => "Log out",
LOGIN_ERROR = "login_error" => "Please enter your name and password.",
LOGIN_SUBTITLE = "login_subtitle" => "Sign in to continue.",
MANAGE = "manage" => "Manage",
MENU = "menu" => "Menu",
@@ -381,7 +380,6 @@ i18n_keys! {
MOVE_TO = "move_to" => "Move “{}” to…",
MOVED = "moved" => "Moved",
NAME = "name" => "Name",
NAME_REQUIRED = "name_required" => "A name is required.",
NAME_TAKEN = "name_taken" => "Name already in use",
NEED_FOLDER = "need_folder" => "Give the user at least one folder.",
NEVER = "never" => "Never",
@@ -429,6 +427,7 @@ i18n_keys! {
PASSKEYS = "passkeys" => "Passkeys",
PASSKEYS_HINT = "passkeys_hint" => "A passkey signs you in with your fingerprint, your face or a security key.",
PASSWORD = "password" => "Password",
PASSWORD_MIN_HINT = "password_min_hint" => "at least 8 characters",
PASSWORD_REMOVED = "password_removed" => "Password removed",
PASSWORD_RESETS_SIGNIN = "password_resets_signin" => "Setting a password also deletes this account's passkeys and drops any two-factor requirement.",
PROFILE = "profile" => "Profile",
@@ -494,7 +493,6 @@ i18n_keys! {
SETTINGS_SAVE_ERR = "settings_save_err" => "Could not save settings: {}",
SETTINGS_SAVED = "settings_saved" => "Settings saved",
SETUP_INTRO = "setup_intro" => "First boot: create the admin account. This user can manage other users and server settings.",
SETUP_NAME_ERR = "setup_name_err" => "Please choose a name (1–64 characters).",
SETUP_PW_MISMATCH = "setup_pw_mismatch" => "Passwords don't match.",
SHARE = "share" => "Share",
SHARE_CREATE_ERR = "share_create_err" => "Could not create share: {}",
@@ -573,8 +571,11 @@ i18n_keys! {
USE_PASSKEY_HINT = "use_passkey_hint" => "Leave the name empty unless your passkey needs it.",
USER_CREATED = "user_created" => "User created",
USER_DELETE_ERR = "user_delete_err" => "Could not delete user",
USER_DISABLED = "user_disabled" => "disabled",
USER_NAME_EXAMPLE = "user_name_example" => "e.g. alice",
USER_SAVE_ERR = "user_save_err" => "Could not save user: {}",
USER_UPDATED = "user_updated" => "User updated",
USER_YOU = "user_you" => "(you)",
USERS = "users" => "Users",
YOUR_SHARES = "your_shares" => "Your shares",
}
@@ -930,7 +931,6 @@ const DE: &[(&str, &str)] = &[
("loading_share", "Freigabe wird geladen…"),
("location", "Ort"),
("log_out", "Abmelden"),
("login_error", "Bitte Name und Passwort eingeben."),
("login_subtitle", "Anmelden, um fortzufahren."),
("manage", "Verwalten"),
("menu", "Menü"),
@@ -950,7 +950,6 @@ const DE: &[(&str, &str)] = &[
("move_to", "„{}“ verschieben nach…"),
("moved", "Verschoben"),
("name", "Name"),
("name_required", "Ein Name ist erforderlich."),
("name_taken", "Name wird bereits verwendet"),
(
"need_folder",
@@ -1025,6 +1024,7 @@ const DE: &[(&str, &str)] = &[
"Ein Passkey meldet Sie mit Fingerabdruck, Gesicht oder Sicherheitsschlüssel an.",
),
("password", "Passwort"),
("password_min_hint", "mindestens 8 Zeichen"),
("password_removed", "Passwort entfernt"),
(
"password_resets_signin",
@@ -1123,10 +1123,6 @@ const DE: &[(&str, &str)] = &[
"setup_intro",
"Erster Start: Erstellen Sie das Administrator-Konto. Dieser Benutzer kann andere Benutzer und die Servereinstellungen verwalten.",
),
(
"setup_name_err",
"Bitte wählen Sie einen Namen (1–64 Zeichen).",
),
("setup_pw_mismatch", "Die Passwörter stimmen nicht überein."),
("share", "Teilen"),
(
@@ -1259,11 +1255,14 @@ const DE: &[(&str, &str)] = &[
),
("user_created", "Benutzer erstellt"),
("user_delete_err", "Benutzer konnte nicht gelöscht werden"),
("user_disabled", "deaktiviert"),
("user_name_example", "z. B. alice"),
(
"user_save_err",
"Benutzer konnte nicht gespeichert werden: {}",
),
("user_updated", "Benutzer aktualisiert"),
("user_you", "(Sie)"),
("users", "Benutzer"),
("your_shares", "Ihre Freigaben"),
];
@@ -1619,10 +1618,6 @@ const FR: &[(&str, &str)] = &[
("loading_share", "Chargement du partage…"),
("location", "Emplacement"),
("log_out", "Se déconnecter"),
(
"login_error",
"Veuillez saisir votre nom et votre mot de passe.",
),
("login_subtitle", "Connectez-vous pour continuer."),
("manage", "Gérer"),
("menu", "Menu"),
@@ -1642,7 +1637,6 @@ const FR: &[(&str, &str)] = &[
("move_to", "Déplacer « {} » vers…"),
("moved", "Déplacé"),
("name", "Nom"),
("name_required", "Un nom est requis."),
("name_taken", "Ce nom est déjà utilisé"),
("need_folder", "Donnez au moins un dossier à l'utilisateur."),
("never", "Jamais"),
@@ -1717,6 +1711,7 @@ const FR: &[(&str, &str)] = &[
"Une clé d'accès vous connecte avec votre empreinte, votre visage ou une clé de sécurité.",
),
("password", "Mot de passe"),
("password_min_hint", "au moins 8 caractères"),
("password_removed", "Mot de passe retiré"),
(
"password_resets_signin",
@@ -1815,10 +1810,6 @@ const FR: &[(&str, &str)] = &[
"setup_intro",
"Premier démarrage : créez le compte administrateur. Cet utilisateur peut gérer les autres utilisateurs et les paramètres du serveur.",
),
(
"setup_name_err",
"Veuillez choisir un nom (1 à 64 caractères).",
),
(
"setup_pw_mismatch",
"Les mots de passe ne correspondent pas.",
@@ -1948,11 +1939,14 @@ const FR: &[(&str, &str)] = &[
),
("user_created", "Utilisateur créé"),
("user_delete_err", "Impossible de supprimer l'utilisateur"),
("user_disabled", "désactivé"),
("user_name_example", "p. ex. alice"),
(
"user_save_err",
"Impossible d'enregistrer l'utilisateur : {}",
),
("user_updated", "Utilisateur mis à jour"),
("user_you", "(vous)"),
("users", "Utilisateurs"),
("your_shares", "Vos partages"),
];
Mweb/src/preview.rs
@@ -1,76 +1,72 @@
//! File previews: browser-native media (image/PDF/video/audio), full page.
//! Text files open in the editor instead.
//! File previews: browser-native media (image/PDF/video/audio), full page,
//! or a download prompt for kinds with no viewer. Text files open in the
//! editor instead.
use api_types::FileKind;
use leptos::prelude::*;
use crate::api;
use crate::components::icon::Icon;
use crate::i18n;
use crate::icons::{IconName, icon_for};
use crate::views::file_view::FileView;
/// What kind of preview a file gets.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PreviewKind {
Image,
Pdf,
Video,
Audio,
/// Whether this kind opens in the media preview. Text goes to the editor,
/// archives and binaries get the "no preview" view.
pub fn is_media(kind: FileKind) -> bool {
matches!(
kind,
FileKind::Image | FileKind::Pdf | FileKind::Video | FileKind::Audio
)
}
/// Which viewer opens this entry, from the server's sniffed [`FileKind`].
/// `None` = nothing we can preview here: text goes to the editor, archives
/// and binaries get the "no preview" view.
pub fn preview_kind(kind: FileKind) -> Option<PreviewKind> {
match kind {
FileKind::Image => Some(PreviewKind::Image),
FileKind::Pdf => Some(PreviewKind::Pdf),
FileKind::Video => Some(PreviewKind::Video),
FileKind::Audio => Some(PreviewKind::Audio),
FileKind::Dir | FileKind::Archive | FileKind::Binary | FileKind::Text => None,
}
}
/// A file the user wants to preview.
#[derive(Clone)]
pub struct PreviewTarget {
pub root_id: i64,
/// Path relative to the root (e.g. "docs/a.txt").
pub path: String,
/// Display name (used for the title + language detection).
pub name: String,
/// The full-page preview (closing lives in the top bar).
#[component]
pub fn Preview(target: FileView) -> impl IntoView {
let url = api::preview_url(target.root_id, &target.path);
let name = target.name.clone();
let media = match target.kind {
FileKind::Image => view! { <img class="preview-img" src=url alt=name/> }.into_any(),
FileKind::Pdf => view! { <iframe class="preview-pdf" src=url title=name/> }.into_any(),
FileKind::Video => {
view! { <video class="preview-video" src=url controls=true/> }.into_any()
}
FileKind::Audio => view! {
<div class="preview-audio">
<audio src=url controls=true/>
</div>
}
.into_any(),
FileKind::Dir | FileKind::Archive | FileKind::Text | FileKind::Binary => {
return view! { <NoPreview target=target/> }.into_any();
}
};
view! { <div class="preview-body file-preview">{media}</div> }.into_any()
}
/// The full-page media preview (closing lives in the top bar).
/// Shown for files with no built-in viewer: a message and a download button.
#[component]
pub fn Preview(target: PreviewTarget, kind: PreviewKind) -> impl IntoView {
fn NoPreview(target: FileView) -> impl IntoView {
let icon = icon_for(target.kind, &target.name);
let name = target.name.clone();
let t = target;
let url = api::preview_url(t.root_id, &t.path);
view! {
<div class="preview-body file-preview">
{move || match kind {
PreviewKind::Image => {
view! { <img class="preview-img" src=url.clone() alt=t.name.clone()/> }
.into_view()
.into_any()
}
PreviewKind::Pdf => {
view! { <iframe class="preview-pdf" src=url.clone() title=t.name.clone()/> }
.into_view()
.into_any()
}
PreviewKind::Video => {
view! { <video class="preview-video" src=url.clone() controls=true/> }
.into_view()
.into_any()
}
PreviewKind::Audio => {
view! {
<div class="preview-audio">
<audio src=url.clone() controls=true/>
</div>
<div class="file-nopreview">
<div class="empty-state">
<Icon name=icon class="empty-glyph".to_string()/>
<h3>{i18n::tr(i18n::k::NO_PREVIEW)}</h3>
<p class="muted">{i18n::t_fmt(i18n::k::NO_PREVIEW_MSG, &name)}</p>
<button
class="btn btn-primary"
on:click=move |_| {
let url = api::download_url(t.root_id, &t.path, None);
api::trigger_download(&url, &name);
}
.into_view()
.into_any()
}
}}
>
<Icon name=IconName::Download class="ic-btn".to_string()/>
{i18n::tr(i18n::k::DOWNLOAD)}
</button>
</div>
</div>
}
}
Mweb/src/theme.rs
@@ -5,8 +5,6 @@
//! "Auto" means no class at all, so the stylesheet falls back to
//! `prefers-color-scheme`.
use wasm_bindgen::JsCast;
use crate::icons::IconName;
const STORAGE_KEY: &str = "fb-theme";
@@ -56,27 +54,15 @@ impl Theme {
}
}
/// Persist the choice. Auto stores nothing (like most sites: "no entry"
/// means "follow the OS"), so clearing the key returns to auto.
/// Persist the choice.
pub fn store(self) {
let Some(store) = local_storage() else {
return;
};
match self {
Theme::Auto => {
let _ = store.remove_item(STORAGE_KEY);
}
t => {
let _ = store.set_item(STORAGE_KEY, t.label());
}
}
crate::util::storage_set(STORAGE_KEY, self.label());
}
/// The persisted choice; anything missing or invalid is
/// [`Theme::Auto`].
pub fn load() -> Theme {
let value = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten());
match value.as_deref() {
match crate::util::storage_get(STORAGE_KEY).as_deref() {
Some("light") => Theme::Light,
Some("dark") => Theme::Dark,
_ => Theme::Auto,
@@ -89,19 +75,12 @@ impl Theme {
let Some(html) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.document_element())
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok())
else {
return;
};
let classes = match self {
html.set_class_name(match self {
Theme::Auto => "",
t => t.label(),
};
html.set_class_name(classes);
});
}
}
/// localStorage, when the browser exposes it (private mode can deny it).
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok()?
}
Mweb/src/util.rs
@@ -162,24 +162,6 @@ pub fn owned_window_listener_capture<E: leptos::ev::EventDescriptor + 'static>(
});
}
/// Select the whole value of the `<input>` behind an event, so one click
/// leaves the link ready to copy by hand.
pub fn select_input(ev: &web_sys::Event) {
if let Some(i) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
i.select();
}
}
/// The value of a `<select>` behind an event, when the target is one.
pub fn select_value(ev: &web_sys::Event) -> Option<String> {
ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
.map(|s| s.value())
}
pub fn format_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut v = bytes as f64;
@@ -312,7 +294,7 @@ pub fn page_footer(
reveal: impl Fn() + 'static,
) -> AnyView {
if total <= shown {
return view! {}.into_view().into_any();
return view! {}.into_any();
}
let label = crate::i18n::t_fmt2(
crate::i18n::k::SEARCH_SHOWN_OF,
@@ -321,9 +303,7 @@ pub fn page_footer(
);
let pageable = retained.saturating_sub(shown);
if pageable == 0 {
return view! { <div class="page-foot">{label}</div> }
.into_view()
.into_any();
return view! { <div class="page-foot">{label}</div> }.into_any();
}
let next = pageable.min(page);
view! {
@@ -334,7 +314,6 @@ pub fn page_footer(
</button>
</div>
}
.into_view()
.into_any()
}
@@ -380,7 +359,7 @@ pub fn pager(
) -> AnyView {
use crate::i18n::k;
if total <= PAGE_SIZES[0] {
return view! {}.into_view().into_any();
return view! {}.into_any();
}
let cur = offset / size;
let pages = total.div_ceil(size);
@@ -448,7 +427,7 @@ pub fn pager(
class="sort-select"
aria-label=crate::i18n::tr(k::PAGE_SIZE)
on:change=move |ev| {
if let Some(n) = select_value(&ev).and_then(|v| v.parse().ok()) {
if let Ok(n) = event_target_value(&ev).parse() {
set_size.run(n);
}
}
@@ -463,7 +442,6 @@ pub fn pager(
</select>
</div>
}
.into_view()
.into_any()
}
Mweb/src/views/admin.rs
@@ -2,9 +2,7 @@
//! Rendered as full views from the shell's "manage" section.
use crate::i18n;
use crate::util::select_value;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me, Mode};
@@ -79,36 +77,25 @@ fn SettingToggle(
value: ReadSignal<Option<bool>>,
set_value: WriteSignal<Option<bool>>,
) -> impl IntoView {
// Built once the value loads, then only `checked` follows it: rebuilding
// the input on each toggle would drop keyboard focus.
view! {
{move || match value.get() {
None => view! {
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
Some(v) => view! {
<label class="setting-row">
<span>
<span class="setting-label">{label}</span>
<span class="setting-desc">{desc}</span>
</span>
<input
type="checkbox"
checked=v
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_value.set(Some(t.checked()));
}
}
/>
</label>
}
.into_view()
.into_any(),
}}
<Show
when=move || value.get().is_some()
fallback=|| view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
>
<label class="setting-row">
<span>
<span class="setting-label">{label}</span>
<span class="setting-desc">{desc}</span>
</span>
<input
type="checkbox"
prop:checked=move || value.get() == Some(true)
on:change=move |ev| set_value.set(Some(event_target_checked(&ev)))
/>
</label>
</Show>
}
}
@@ -171,26 +158,20 @@ pub fn SettingsView(
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;
let set_root = set_default_root;
spawn_local(async move {
match api::update_profile(Some(v), Some(th), Some(lang.clone()), Some(root)).await {
Ok(m) => {
show(toast2, i18n::t(i18n::k::PROFILE_SAVED).to_string());
show(toast, 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));
set_root.set(Some(root));
set_me.set(Some(m));
set_single_click.set(Some(v));
set_thumbnails.set(Some(th));
set_language.set(Some(lang));
set_default_root.set(Some(root));
}
Err(e) => show(
toast2,
toast,
i18n::t_fmt(i18n::k::PROFILE_SAVE_ERR, &e.to_string()),
),
}
@@ -203,25 +184,20 @@ pub fn SettingsView(
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
// endpoint (it would 403 and toast).
if !is_admin() {
return;
}
match api::get_admin_settings().await {
Ok(s) => {
set.set(Some(s.allow_writable_shares));
set_ex.set(s.search_excludes);
}
Err(e) => show_error(toast2, e.to_string()),
spawn_local(async move {
// Non-admins don't have a Server tab; don't even probe the
// endpoint (it would 403 and toast).
if !is_admin() {
return;
}
match api::get_admin_settings().await {
Ok(s) => {
set_value.set(Some(s.allow_writable_shares));
set_excludes.set(s.search_excludes);
}
});
}
Err(e) => show_error(toast, 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 |_| {
@@ -258,20 +234,18 @@ pub fn SettingsView(
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, ex).await {
Ok(_) => {
show(toast2, i18n::t(i18n::k::SETTINGS_SAVED).to_string());
show(toast, i18n::t(i18n::k::SETTINGS_SAVED).to_string());
// Re-fetch this session's /me so the share dialog (and
// anything else) sees the new setting without a reload.
if let Ok(m) = api::me().await {
set_me2.set(Some(m));
set_me.set(Some(m));
}
}
Err(e) => show(
toast2,
toast,
i18n::t_fmt(i18n::k::SETTINGS_SAVE_ERR, &e.to_string()),
),
}
@@ -282,73 +256,42 @@ pub fn SettingsView(
view! {
<div class="admin-view">
<div class="settings-tabs">
<button
class="settings-tab-btn"
class:active=move || tab.get() == SettingsTab::Profile
on:click=move |_| pick_tab(SettingsTab::Profile)
>
{i18n::tr(i18n::k::PROFILE)}
</button>
<button
class="settings-tab-btn"
class:active=move || tab.get() == SettingsTab::Security
on:click=move |_| pick_tab(SettingsTab::Security)
>
{i18n::tr(i18n::k::SECURITY)}
</button>
<Show when=move || is_admin()>
<button
class="settings-tab-btn"
class:active=move || tab.get() == SettingsTab::Server
on:click=move |_| pick_tab(SettingsTab::Server)
>
{i18n::tr(i18n::k::SERVER)}
</button>
</Show>
{[
(SettingsTab::Profile, i18n::k::PROFILE),
(SettingsTab::Security, i18n::k::SECURITY),
(SettingsTab::Server, i18n::k::SERVER),
]
.map(|(t, label)| view! {
<Show when=move || t != SettingsTab::Server || is_admin()>
<button
class="settings-tab-btn"
class:active=move || tab.get() == t
on:click=move |_| pick_tab(t)
>
{i18n::tr(label)}
</button>
</Show>
})}
</div>
<Show when=move || tab.get() == SettingsTab::Profile>
{move || match (single_click.get(), language.get(), default_root.get()) {
(Some(sc), Some(lang), Some(root)) => {
{move || match (language.get(), default_root.get()) {
(Some(lang), Some(root)) => {
let lang_val = lang.clone().unwrap_or_default();
let roots = me.get().map(|m| m.roots).unwrap_or_default();
view! {
<label class="setting-row">
<span>
<span class="setting-label">{i18n::t(i18n::k::SINGLE_CLICK_LABEL)}</span>
<span class="setting-desc">{i18n::t(i18n::k::SINGLE_CLICK_DESC)}</span>
</span>
<input
type="checkbox"
checked=sc
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_single_click.set(Some(t.checked()));
}
}
/>
</label>
<SettingToggle
label=i18n::t(i18n::k::SINGLE_CLICK_LABEL)
desc=i18n::t(i18n::k::SINGLE_CLICK_DESC)
value=single_click
set_value=set_single_click
/>
<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>
<SettingToggle
label=i18n::t(i18n::k::THUMBNAILS_LABEL)
desc=i18n::t(i18n::k::THUMBNAILS_DESC)
value=thumbnails
set_value=set_thumbnails
/>
</Show>
<div class="setting-row setting-row-select">
<span>
@@ -356,15 +299,9 @@ pub fn SettingsView(
<span class="setting-desc">{i18n::t(i18n::k::LANGUAGE_DESC)}</span>
</span>
<select
on:change=move |ev: web_sys::Event| {
let Some(tag) = select_value(&ev) else {
return;
};
set_language.set(Some(if tag.is_empty() {
None
} else {
Some(tag)
}));
on:change=move |ev| {
let tag = event_target_value(&ev);
set_language.set(Some((!tag.is_empty()).then_some(tag)));
}
>
<option value="" selected=lang_val.is_empty()>{i18n::t(i18n::k::LANG_AUTO)}</option>
@@ -379,11 +316,8 @@ pub fn SettingsView(
<span class="setting-desc">{i18n::t(i18n::k::DEFAULT_ROOT_DESC)}</span>
</span>
<select
on:change=move |ev: web_sys::Event| {
let Some(v) = select_value(&ev) else {
return;
};
set_default_root.set(Some(v.parse().ok()));
on:change=move |ev| {
set_default_root.set(Some(event_target_value(&ev).parse().ok()))
}
>
<option value="" selected=root.is_none()>{i18n::t(i18n::k::DEFAULT_ROOT_NONE)}</option>
@@ -409,13 +343,11 @@ pub fn SettingsView(
</button>
</div>
}
.into_view()
.into_any()
}
_ => view! {
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
}}
</Show>
@@ -440,7 +372,6 @@ pub fn SettingsView(
{i18n::tr(i18n::k::SEARCH_EXCLUDES_EMPTY)}
</p>
}
.into_view()
.into_any();
}
list.iter()
@@ -469,7 +400,6 @@ pub fn SettingsView(
}
})
.collect::<Vec<_>>()
.into_view()
.into_any()
}}
</div>
@@ -477,12 +407,7 @@ pub fn SettingsView(
<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()
}}
{move || excl_error.get().map(|e| view! { <p class="dialog-error">{e}</p> })}
</div>
<div class="modal-actions">
<button
@@ -526,12 +451,10 @@ pub fn UsersView(
let self_id = move || me.get().and_then(|m| m.user).map(|u| u.id);
let reload = move || {
let set = set_users;
let toast2 = toast;
spawn_local(async move {
match api::list_admin_users().await {
Ok(u) => set.set(Some(u)),
Err(e) => show_error(toast2, e.to_string()),
Ok(u) => set_users.set(Some(u)),
Err(e) => show_error(toast, e.to_string()),
}
});
};
@@ -559,17 +482,14 @@ pub fn UsersView(
return;
}
let id = u.id;
let read = users;
let write = set_users;
let toast2 = toast;
spawn_local(async move {
if api::delete_admin_user(id).await.is_ok() {
if let Some(mut cur) = read.get() {
if let Some(mut cur) = users.get() {
cur.retain(|x| x.id != id);
write.set(Some(cur));
set_users.set(Some(cur));
}
} else {
show_error(toast2, i18n::t(i18n::k::USER_DELETE_ERR).to_string());
show_error(toast, i18n::t(i18n::k::USER_DELETE_ERR).to_string());
}
});
};
@@ -587,12 +507,10 @@ pub fn UsersView(
None => view! {
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
Some(ref list) if list.is_empty() => view! {
<p class="muted">{i18n::tr(i18n::k::NO_USERS)}</p>
}
.into_view()
.into_any(),
Some(ref list) => {
let sid = self_id();
@@ -603,46 +521,22 @@ pub fn UsersView(
let is_admin = u.is_admin;
let u_edit = u.clone();
let u_del = u.clone();
let del = delete;
let set_edit = set_editing;
view! {
<div class="user-row">
<div class="user-row-main">
<div class="user-row-name">
{move || {
if is_admin {
view! { <span class="ic-admin" title="Administrator"><Icon name=IconName::Admin/></span> }
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
{is_admin.then(|| view! {
<span class="ic-admin" title=i18n::tr(i18n::k::ADMINISTRATOR)><Icon name=IconName::Admin/></span>
})}
{u.name.clone()}
{if is_self {
view! { <span class="muted">" (you)"</span> }
.into_view()
.into_any()
} else {
{
view! {}.into_view()
}.into_any()
}}
{if !u.active {
view! { <span class="badge badge-off">"disabled"</span> }
.into_view()
.into_any()
} else {
{
view! {}.into_view()
}.into_any()
}}
{is_self.then(|| view! { <span class="muted">" " {i18n::tr(i18n::k::USER_YOU)}</span> })}
{(!u.active).then(|| view! { <span class="badge badge-off">{i18n::tr(i18n::k::USER_DISABLED)}</span> })}
</div>
</div>
<div class="user-row-actions">
<button
class="btn btn-sm"
on:click=move |_| set_edit.set(Some(Editing::Edit(u_edit.clone())))
on:click=move |_| set_editing.set(Some(Editing::Edit(u_edit.clone())))
>
{i18n::tr(i18n::k::EDIT)}
</button>
@@ -654,7 +548,7 @@ pub fn UsersView(
} else {
i18n::tr(i18n::k::DELETE_USER)
}
on:click=move |_| del(u_del.clone())
on:click=move |_| delete(u_del.clone())
>
{i18n::tr(i18n::k::DELETE)}
</button>
@@ -667,33 +561,23 @@ pub fn UsersView(
<div class="user-list">{rows}</div>
}
}
.into_view()
.into_any(),
}}
{move || match editing.get() {
Some(ref e) => {
let existing = match e {
Editing::Edit(u) => Some(u.clone()),
Editing::New => None,
};
let sid = self_id();
let set = set_editing;
view! {
<UserForm
existing=existing
self_id=sid
close=Callback::new(move |_| set.set(None))
on_saved=on_saved
set_dialog=set_dialog
/>
}
.into_view()
.into_any()
{move || editing.get().map(|e| {
let existing = match e {
Editing::Edit(u) => Some(u),
Editing::New => None,
};
view! {
<UserForm
existing=existing
self_id=self_id()
close=Callback::new(move |_| set_editing.set(None))
on_saved=on_saved
set_dialog=set_dialog
/>
}
None => {
view! {}.into_any()
},
}}
})}
</div>
}
}
@@ -786,7 +670,8 @@ fn UserForm(
}));
};
let save = move |_| {
let save = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
if busy.get() {
return;
}
@@ -799,21 +684,7 @@ fn UserForm(
.iter()
.map(|r| (r.path.clone(), r.mode))
.collect();
// Client-side validation.
if n.is_empty() {
set_error.set(Some(i18n::t(i18n::k::NAME_REQUIRED).to_string()));
return;
}
if is_new && pw.len() < 8 {
set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string()));
return;
}
if !is_new && pw.is_empty() {
// keep password unchanged
} else if pw.len() < 8 {
set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string()));
return;
}
// The inputs check the name and the password length.
if pairs.is_empty() {
set_error.set(Some(i18n::t(i18n::k::NEED_FOLDER).to_string()));
return;
@@ -821,9 +692,6 @@ fn UserForm(
set_busy.set(true);
set_error.set(None);
let toast2 = toast;
let on_saved2 = on_saved;
let close2 = close;
let existing_id = existing.as_ref().map(|u| u.id);
spawn_local(async move {
let result = if is_new {
@@ -840,18 +708,18 @@ fn UserForm(
match result {
Ok(_) => {
show(
toast2,
toast,
if is_new {
i18n::t(i18n::k::USER_CREATED).to_string()
} else {
i18n::t(i18n::k::USER_UPDATED).to_string()
},
);
on_saved2.run(existing_id);
close2.run(());
on_saved.run(existing_id);
close.run(());
}
Err(e) => {
show_error(toast2, i18n::t_fmt(i18n::k::USER_SAVE_ERR, &e.to_string()));
show_error(toast, i18n::t_fmt(i18n::k::USER_SAVE_ERR, &e.to_string()));
set_busy.set(false);
}
}
@@ -860,6 +728,7 @@ fn UserForm(
view! {
<Modal class="user-form" on_close=close>
<form on:submit=save>
<h2 class="modal-title">{move || {
if is_new {
i18n::t(i18n::k::NEW_USER).to_string()
@@ -876,15 +745,9 @@ fn UserForm(
class="field-input"
value=n0.clone()
disabled=name_readonly
placeholder="e.g. alice"
on:input=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_name.set(t.value());
}
}
required=true
placeholder=i18n::tr(i18n::k::USER_NAME_EXAMPLE)
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</label>
}
@@ -901,15 +764,10 @@ fn UserForm(
class="field-input"
type="password"
value=move || password.get()
placeholder="at least 8 characters"
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());
}
}
required=is_new
minlength="8"
placeholder=i18n::tr(i18n::k::PASSWORD_MIN_HINT)
on:input=move |ev| set_password.set(event_target_value(&ev))
/>
</label>
{(!is_new)
@@ -923,48 +781,26 @@ fn UserForm(
type="checkbox"
checked=move || is_admin.get()
disabled=is_self
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_is_admin.set(t.checked());
}
}
on:change=move |ev| set_is_admin.set(event_target_checked(&ev))
/>
{i18n::tr(i18n::k::ADMINISTRATOR)}
</label>
{move || if !is_new {
view! {
<label class="check-row">
<input
type="checkbox"
checked=move || active.get()
disabled=is_self
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_active.set(t.checked());
}
}
/>
{i18n::tr(i18n::k::ACTIVE_CAN_SIGNIN)}
</label>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}}
{(!is_new).then(|| view! {
<label class="check-row">
<input
type="checkbox"
checked=move || active.get()
disabled=is_self
on:change=move |ev| set_active.set(event_target_checked(&ev))
/>
{i18n::tr(i18n::k::ACTIVE_CAN_SIGNIN)}
</label>
})}
<div class="field">
<span class="field-label">{i18n::tr(i18n::k::FOLDERS)}</span>
<p class="muted field-hint">{i18n::tr(i18n::k::FOLDERS_PATHS_HINT)}</p>
<div class="roots-editor">
{move || {
let set_roots_c = set_roots;
let roots_c = roots;
roots
.get()
.iter()
@@ -977,35 +813,29 @@ fn UserForm(
<span class="root-draft-path">{r.path.clone()}</span>
<select
class="root-draft-mode"
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
on:change=move |ev| {
let mut v = roots.get();
if i < v.len()
&& let Some(m) = Mode::from_wire(&event_target_value(&ev))
{
let mut v = roots_c.get();
if i < v.len()
&& let Some(m) = Mode::from_wire(
&t.value(),
)
{
v[i].mode = m;
}
set_roots_c.set(v);
v[i].mode = m;
}
set_roots.set(v);
}
>
<option value=Mode::Rw.as_str() selected=rw_selected>{i18n::t(i18n::k::MODE_RW)}</option>
<option value=Mode::Ro.as_str() selected=ro_selected>{i18n::t(i18n::k::MODE_RO)}</option>
</select>
<button
type="button"
class="icon-btn icon-btn-sm"
title={i18n::tr(i18n::k::REMOVE_FOLDER)}
on:click=move |_| {
let mut v = roots_c.get();
let mut v = roots.get();
if i < v.len() {
v.remove(i);
}
set_roots_c.set(v);
set_roots.set(v);
}
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
@@ -1015,28 +845,19 @@ fn UserForm(
})
.collect::<Vec<_>>()
}}
<button class="btn root-add" on:click=add_root>
<button type="button" class="btn root-add" on:click=add_root>
<Icon name=IconName::Folder class="ic-btn".to_string()/>
{i18n::tr(i18n::k::ADD_FOLDER)}
</button>
</div>
</div>
{move || match error.get() {
Some(ref e) => view! {
<p class="form-error">{e.clone()}</p>
}
.into_view()
.into_any(),
None => {
view! {}.into_any()
},
}}
{move || error.get().map(|e| view! { <p class="form-error">{e}</p> })}
<div class="modal-actions">
<button class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
<button type="button" class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
<button
type="submit"
class="btn btn-primary"
disabled=move || busy.get()
on:click=save
>
{move || {
if busy.get() {
@@ -1049,6 +870,7 @@ fn UserForm(
}}
</button>
</div>
</form>
</Modal>
}
}
Mweb/src/views/browser.rs
@@ -1,7 +1,7 @@
//! The file browser: breadcrumbs, grid/list views, root picker, context menu.
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use api_types::FileKind;
use leptos::prelude::*;
@@ -12,17 +12,16 @@ use web_sys::MouseEvent;
use crate::api::{self, Entry, Me, RootInfo};
use crate::components::icon::{Icon, icon_svg};
use crate::components::toast::{ToastMsg, show, show_error};
use crate::editor::EditTarget;
use crate::i18n;
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::preview::is_media;
use crate::router::{self, Location, navigate};
use crate::uploads::{self, OnConflict};
use crate::util::{
LongPress, SortKey, SortSpec, ViewMode, format_date, format_size, pager, save_page_size,
};
use crate::views::dialogs::{Dialog, Op};
use crate::views::file_view::{FileView, UnsupportedTarget};
use crate::views::file_view::FileView;
/// What a listing fetch asks for. Only a change of it fetches again.
#[derive(Clone, Debug, PartialEq)]
@@ -211,18 +210,17 @@ pub fn Browser(
// The selection follows the shown file, so closing the preview puts
// focus and the action bar on the file that was actually open.
let show_preview = move |e: &Entry, root_id: i64| {
let Some(kind) = preview_kind(e.kind) else {
if !is_media(e.kind) {
return;
};
}
set_selected.set(vec![e.clone()]);
open_file.set(Some(FileView::Preview(
PreviewTarget {
root_id,
path: join_path(&loc.get_untracked().path, &e.name),
name: e.name.clone(),
},
kind,
)));
open_file.set(Some(FileView {
root_id,
path: join_path(&loc.get_untracked().path, &e.name),
name: e.name.clone(),
kind: e.kind,
readonly: false,
}));
};
// Fetch the page in the URL. `force` fetches it again (a refresh).
@@ -246,9 +244,6 @@ pub fn Browser(
// On a public share page the (synthetic) "me" comes from the share
// itself, not from a session — refreshing /me would be wrong there.
let is_share = loc.with_untracked(|l| l.share_token.is_some());
let set_me2 = set_me;
let refresh_key = me_refresh_key;
let set_refresh_key = set_me_refresh_key;
spawn_local(async move {
let ListReq {
root_id,
@@ -297,12 +292,12 @@ pub fn Browser(
Err(e) => {
if !is_share
&& matches!(e.status(), Some(403) | Some(404))
&& refresh_key.get() != Some((root_id, path_str.clone()))
&& me_refresh_key.get() != Some((root_id, path_str.clone()))
{
set_refresh_key.set(Some((root_id, path_str.clone())));
set_me_refresh_key.set(Some((root_id, path_str.clone())));
spawn_local(async move {
if let Ok(m) = api::me().await {
set_me2.set(Some(m));
set_me.set(Some(m));
}
});
}
@@ -397,7 +392,7 @@ pub fn Browser(
});
return;
}
let Some(FileView::Preview(cur, _)) = file_view.get_untracked() else {
let Some(cur) = file_view.get_untracked().filter(|v| is_media(v.kind)) else {
back(step);
return;
};
@@ -445,13 +440,9 @@ pub fn Browser(
}
}
});
{
let w = set_browser_refresh;
let f = fetch;
Effect::new(move |_| {
w.set(Some(f));
});
}
Effect::new(move |_| {
set_browser_refresh.set(Some(fetch));
});
// Left / Right in an open media preview steps to the previous or next
// previewable file, in the listing's current order. Past the edge of the
@@ -474,7 +465,7 @@ pub fn Browser(
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 {
let Some(cur) = file_view.get().filter(|v| is_media(v.kind)) else {
return;
};
let ListState::Entries(list) = list_state.get() else {
@@ -619,14 +610,10 @@ pub fn Browser(
view! {
<div class="browser">
{move || {
let Some(me_now) = me.get() else {
return {
view! {}.into_any()
};
};
let me_now = me.get()?;
folder.with(|_| ());
let loc_now = loc.get_untracked();
match effective_root(&me_now.roots, &loc_now) {
Some(match effective_root(&me_now.roots, &loc_now) {
Some(root) => file_browser(
me,
root,
@@ -678,7 +665,7 @@ pub fn Browser(
)
.into_any(),
None => no_folder_view(&me_now.roots, &loc_now).into_any(),
}
})
}}
<CtxMenuView
ctx=ctx
@@ -822,6 +809,15 @@ fn file_browser(
</div>
};
// The title names the current order, not the one the click would switch
// to: the arrow already shows it.
let sort_label = move || {
i18n::t(if sort.get().asc {
i18n::k::SORT_ASC
} else {
i18n::k::SORT_DESC
})
};
// Toolbar: refresh on the left, view toggles on the right. The
// selection's action buttons live in the app's top bar.
let toolbar = view! {
@@ -840,8 +836,7 @@ fn file_browser(
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 Some(key) = SortKey::parse(&event_target_value(&ev)) else { return };
set_sort(SortSpec { key, asc: sort.get().asc });
}
>
@@ -864,22 +859,8 @@ fn file_browser(
</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)
}
}
title=sort_label
aria-label=sort_label
on:click=move |_| {
let cur = sort.get();
set_sort(SortSpec { key: cur.key, asc: !cur.asc });
@@ -894,38 +875,23 @@ fn file_browser(
icon_svg(name, "ic-btn")
}}
</button>
<button
class=move || {
if view_mode.get() == ViewMode::Grid {
"icon-btn active".to_string()
} else {
"icon-btn".to_string()
}
}
title=i18n::tr(i18n::k::GRID_VIEW)
on:click=move |_| {
set_view_mode.set(ViewMode::Grid);
ViewMode::Grid.save();
}
>
<Icon name=IconName::Grid class="ic-btn".to_string()/>
</button>
<button
class=move || {
if view_mode.get() == ViewMode::List {
"icon-btn active".to_string()
} else {
"icon-btn".to_string()
}
}
title=i18n::tr(i18n::k::LIST_VIEW)
on:click=move |_| {
set_view_mode.set(ViewMode::List);
ViewMode::List.save();
}
>
<Icon name=IconName::List class="ic-btn".to_string()/>
</button>
{[
(ViewMode::Grid, i18n::k::GRID_VIEW, IconName::Grid),
(ViewMode::List, i18n::k::LIST_VIEW, IconName::List),
]
.map(|(mode, label, icon)| view! {
<button
class="icon-btn"
class:active=move || view_mode.get() == mode
title=i18n::tr(label)
on:click=move |_| {
set_view_mode.set(mode);
mode.save();
}
>
<Icon name=icon class="ic-btn".to_string()/>
</button>
})}
</div>
</div>
};
@@ -1007,12 +973,10 @@ fn file_browser(
ListState::Entries(list) if stale(&list) => view! {
<p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
ListState::Loading => view! {
<p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
ListState::Error(msg) => view! {
<div class="card error-card">
@@ -1022,12 +986,10 @@ fn file_browser(
</button>
</div>
}
.into_view()
.into_any(),
ListState::Entries(list) => {
if list.entries.is_empty() {
return view! { <p class="muted center-note">{i18n::tr(i18n::k::EMPTY_FOLDER)}</p> }
.into_view()
.into_any();
}
let (offset, shown, total, size) =
@@ -1066,7 +1028,6 @@ fn file_browser(
{body}
{pager(offset, shown, total, size, go, set_size)}
}
.into_view()
.into_any()
}
}
@@ -1081,32 +1042,10 @@ fn file_browser(
}
}
/// Right-click on an entry: open the context menu on the selection. An
/// entry that is already selected keeps the whole selection (multi-item
/// menu); any other entry becomes the only selected one.
fn ctx_menu_handler(
all: Arc<Listing>,
idx: usize,
set_ctx: WriteSignal<Option<CtxMenu>>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
) -> impl Fn(web_sys::MouseEvent) + 'static {
move |ev: MouseEvent| {
ev.prevent_default();
ev.stop_propagation();
open_entry_ctx(
&all,
idx,
set_ctx,
selected,
set_selected,
(ev.client_x(), ev.client_y()),
);
}
}
/// Open the context menu for one entry at `at`. Shared by the right click
/// and the touch long press.
/// and the touch long press. An entry that is already selected keeps the
/// whole selection (multi-item menu); any other entry becomes the only
/// selected one.
fn open_entry_ctx(
all: &Listing,
idx: usize,
@@ -1272,70 +1211,16 @@ fn open_entry(
navigate(&Location::folder(Some(root_id), cur.share_token, path));
return;
}
let full = join_path(&loc.get().path, &name);
if kind == FileKind::Text {
open_file.set(Some(FileView::Editor(EditTarget {
root_id,
path: full,
name: name.clone(),
readonly: !is_rw,
})));
return;
}
match preview_kind(kind) {
Some(k) => open_file.set(Some(FileView::Preview(
PreviewTarget {
root_id,
path: full,
name: name.clone(),
},
k,
))),
None => open_file.set(Some(FileView::Unsupported(UnsupportedTarget {
root_id,
path: full,
name: name.clone(),
kind,
}))),
}
open_file.set(Some(FileView {
root_id,
path: join_path(&loc.get().path, &name),
name: name.clone(),
kind,
readonly: !is_rw,
}));
})
}
/// Click/Enter on an entry: the shared "open" action, the selection-aware
/// click handler, and the context-menu handler. The grid and the list differ
/// only in markup.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn entry_callbacks(
all: Arc<Listing>,
idx: usize,
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
open_file: WriteSignal<Option<FileView>>,
) -> (
Callback<()>,
impl Fn(web_sys::MouseEvent) + 'static,
impl Fn(web_sys::MouseEvent) + 'static,
) {
let open_cb = open_entry(&all.entries[idx], root_id, loc, is_rw, open_file);
(
open_cb,
selection_click(
all.clone(),
idx,
single_click,
selected,
set_selected,
open_cb,
),
ctx_menu_handler(all, idx, set_ctx, selected, set_selected),
)
}
/// 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
@@ -1469,10 +1354,9 @@ fn entries_view(
let title = name.clone();
let size = if e.is_dir { "—".to_string() } else { format_size(e.size) };
let date = format_date(&e.mtime);
let (open_cb, on_click, on_ctx) = entry_callbacks(
all.clone(), i, root_id, loc, is_rw, single_click,
selected, set_selected, set_ctx, open_file,
);
let open_cb = open_entry(e, root_id, loc, is_rw, open_file);
let on_click =
selection_click(all.clone(), i, single_click, selected, set_selected, open_cb);
let thumb_src = (thumbs
&& !e.is_dir
&& matches!(e.kind, FileKind::Image | FileKind::Video))
@@ -1482,6 +1366,7 @@ fn entries_view(
let selected_now = move || sel_names.with(|s| s.contains(&item_name));
let press = LongPress::new();
let all_press = all.clone();
let all_ctx = all.clone();
view! {
<div
class=item
@@ -1520,7 +1405,12 @@ fn entries_view(
};
arrow_key(&ev, &container, &all_keys, i, grid, selected, set_selected, turn_page);
}
on:contextmenu=on_ctx
on:contextmenu=move |ev: MouseEvent| {
ev.prevent_default();
ev.stop_propagation();
let at = (ev.client_x(), ev.client_y());
open_entry_ctx(&all_ctx, i, set_ctx, selected, set_selected, at);
}
title=title
>
{if grid {
@@ -1613,13 +1503,29 @@ fn menu_items(
IconName::NewFolder,
i18n::t(i18n::k::NEW_FOLDER),
is_rw,
action_new_folder(root_id, loc, refresh, toast, set_dialog, owner.clone()),
action_name(
NameOp::Mkdir,
root_id,
loc,
refresh,
toast,
set_dialog,
owner.clone(),
),
),
MenuItem::rw(
IconName::NewFile,
i18n::t(i18n::k::NEW_FILE),
is_rw,
action_new_file(root_id, loc, refresh, toast, set_dialog, owner.clone()),
action_name(
NameOp::CreateFile,
root_id,
loc,
refresh,
toast,
set_dialog,
owner.clone(),
),
),
MenuItem::rw(
IconName::Upload,
@@ -1723,7 +1629,15 @@ fn menu_items(
IconName::Rename,
i18n::t(i18n::k::RENAME),
is_rw,
action_rename(e, root_id, loc, refresh, toast, set_dialog, owner.clone()),
action_name(
NameOp::Rename(e.name.clone()),
root_id,
loc,
refresh,
toast,
set_dialog,
owner.clone(),
),
));
}
v.push(MenuItem::rw(
@@ -1857,16 +1771,12 @@ fn CtxMenuView(
view! {
{move || {
let Some((mx, my)) = ctx.get() else {
return {
view! {}.into_any()
};
};
let (mx, my) = ctx.get()?;
let items = selected.with(|sel| {
menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone())
});
view! {
Some(view! {
<div
class="ctx-menu"
node_ref=menu_ref
@@ -1876,9 +1786,7 @@ fn CtxMenuView(
view! { <CtxItem item=item/> }
}).collect::<Vec<_>>()}
</div>
}
.into_view()
.into_any()
})
}}
}
}
@@ -1965,20 +1873,6 @@ pub fn SelectionActions(
// so the first paint never overflows; the effect below widens it as soon
// as the real widths are measurable.
let (visible_n, set_visible_n) = signal(3usize);
// The three-dot overflow menu state. Any outside click or Escape closes
// it; a new selection does too. The toggle button stops propagation so
// it can actually open the menu.
let (more_open, set_more_open) = signal(false);
crate::util::owned_window_listener(leptos::ev::click, move |_| set_more_open.set(false));
crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
set_more_open.set(false);
}
});
Effect::new(move |_| {
let _ = selected.get();
set_more_open.set(false);
});
let group_ref = NodeRef::<leptos::html::Div>::new();
let recompute = move || {
@@ -2044,9 +1938,7 @@ pub fn SelectionActions(
.is_some_and(|m| loc.with(|l| effective_root(&m.roots, l).is_some()))
});
if !has_root {
return {
view! {}.into_any()
};
return None;
}
let items = selected.with(|sel| {
menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone())
@@ -2076,31 +1968,49 @@ pub fn SelectionActions(
<CtxItem item=i/>
})
.collect_view();
let btn = NodeRef::<leptos::html::Button>::new();
let menu = NodeRef::<leptos::html::Div>::new();
// The popover sits in the top layer, outside this layout,
// so it is placed under the button by hand.
let (at, set_at) = signal((0.0, 0.0));
view! {
<div class="sel-more-wrap">
<button
class="icon-btn"
title=i18n::tr(i18n::k::MORE_ACTIONS)
aria-label=move || i18n::t(i18n::k::MORE_ACTIONS).to_string()
on:click=move |ev: MouseEvent| {
ev.stop_propagation();
set_more_open.set(!more_open.get());
<button
class="icon-btn"
node_ref=btn
popovertarget="sel-more-menu"
title=i18n::tr(i18n::k::MORE_ACTIONS)
aria-label=move || i18n::t(i18n::k::MORE_ACTIONS).to_string()
on:click=move |_| {
let (Some(b), Some(doc)) = (btn.get(), document().document_element()) else {
return;
};
let r = b.get_bounding_client_rect();
set_at.set((r.bottom() + 6.0, f64::from(doc.client_width()) - r.right()));
}
>
<Icon name=IconName::MoreVert class="ic-btn".to_string()/>
</button>
<div
id="sel-more-menu"
class="sel-more-menu"
popover="auto"
node_ref=menu
style:top=move || format!("{}px", at.get().0)
style:right=move || format!("{}px", at.get().1)
on:click=move |_| {
if let Some(m) = menu.get() {
let _ = m.hide_popover();
}
>
<Icon name=IconName::MoreVert class="ic-btn".to_string()/>
</button>
<div class="sel-more-menu" class:hidden=move || !more_open.get()>
{more_view}
</div>
}
>
{more_view}
</div>
}
});
view! {
Some(view! {
{visible_view}
{more_block}
}
.into_view()
.into_any()
})
}}
</div>
}
@@ -2137,7 +2047,7 @@ fn SelButton(item: MenuItem) -> impl IntoView {
// ---------------------------------------------------------------------------
fn previewable(e: &Entry) -> bool {
!e.is_dir && preview_kind(e.kind).is_some()
!e.is_dir && is_media(e.kind)
}
fn join_path(dir: &[String], name: &str) -> String {
@@ -2152,108 +2062,17 @@ fn is_conflict(e: &api::ApiError) -> bool {
matches!(e, api::ApiError::Http { status: 409, .. })
}
/// A file operation in flight. Boxed because each caller builds a different
/// future, and they all have to fit one type.
type OpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), api::ApiError>>>>;
/// Run a file operation that may collide with an existing name.
///
/// `op(overwrite)` builds the request. On a 409 the colliding names are shown
/// in a dialog, and confirming re-runs `op(true)`. Rename, move, copy and
/// upload all need exactly this, so it lives here once.
///
/// `names` is what to list in the dialog when the server does not say. Upload
/// reports the exact set it skipped; the single-item operations know theirs
/// before they ask.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn run_with_overwrite_retry(
op: impl Fn(bool) -> OpFuture + Clone + Send + Sync + 'static,
conflict_title: &'static str,
names: Vec<String>,
ok_msg: &'static str,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
) {
fn done(ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg) {
show(toast, ok_msg);
refresh.run(());
}
let retry = op.clone();
spawn_local(async move {
let Err(e) = op(false).await else {
done(ok_msg, refresh, toast);
return;
};
let conflicting = e
.skipped()
.map(<[String]>::to_vec)
.or_else(|| is_conflict(&e).then_some(names));
let Some(files) = conflicting else {
show_error(toast, e.to_string());
return;
};
crate::views::dialogs::claim_async_dialog(dialog).await;
set_dialog.set(Some(Dialog::Conflict {
title: conflict_title.to_string(),
files,
on_submit: Callback::new(move |_| {
let retry = retry.clone();
spawn_local(async move {
match retry(true).await {
Ok(()) => done(ok_msg, refresh, toast),
Err(e) => show_error(toast, e.to_string()),
}
});
}),
}));
});
}
fn action_new_folder(
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let dir = loc.path.clone();
let toast2 = toast;
let refresh2 = refresh;
set_dialog.set(Some(Dialog::PromptOp {
title: i18n::t(i18n::k::NEW_FOLDER).to_string(),
label: i18n::t(i18n::k::FOLDER_NAME).to_string(),
initial: String::new(),
submit: i18n::t(i18n::k::CREATE).to_string(),
run: owner.with(|| {
Callback::new(move |name: String| {
let full = join_path(&dir, &name);
let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
api::mkdir(root_id, &full)
.await
.map(|_| ())
.map_err(|e| e.to_string())
});
fut
})
}),
on_ok: owner.with(|| {
Callback::new(move |_| {
show(toast2, i18n::t(i18n::k::FOLDER_CREATED));
refresh2.run(());
})
}),
}));
})
/// What a name prompt in the current folder does with the entered name.
#[derive(Clone)]
enum NameOp {
Mkdir,
CreateFile,
/// The entry's current name.
Rename(String),
}
fn action_new_file(
fn action_name(
op: NameOp,
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
@@ -2261,74 +2080,61 @@ fn action_new_file(
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
use i18n::k;
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let dir = loc.path.clone();
let toast2 = toast;
let refresh2 = refresh;
let dir = loc.get().path;
let (title, label, initial, submit, ok) = match &op {
NameOp::Mkdir => (
i18n::t(k::NEW_FOLDER).to_string(),
k::FOLDER_NAME,
String::new(),
k::CREATE,
k::FOLDER_CREATED,
),
NameOp::CreateFile => (
i18n::t(k::NEW_FILE).to_string(),
k::FILE_NAME,
String::new(),
k::CREATE,
k::FILE_CREATED,
),
NameOp::Rename(old) => (
i18n::t_fmt(k::RENAME_TITLE, old),
k::NEW_NAME,
old.clone(),
k::RENAME,
k::RENAMED,
),
};
let op = op.clone();
set_dialog.set(Some(Dialog::PromptOp {
title: i18n::t(i18n::k::NEW_FILE).to_string(),
label: i18n::t(i18n::k::FILE_NAME).to_string(),
initial: String::new(),
submit: i18n::t(i18n::k::CREATE).to_string(),
title,
label: i18n::t(label).to_string(),
initial,
submit: i18n::t(submit).to_string(),
run: owner.with(|| {
Callback::new(move |name: String| {
let full = join_path(&dir, &name);
let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
api::create_file(root_id, &full)
.await
.map(|_| ())
.map_err(|e| e.to_string())
});
fut
})
}),
on_ok: owner.with(|| {
Callback::new(move |_| {
show(toast2, i18n::t(i18n::k::FILE_CREATED));
refresh2.run(());
})
}),
}));
})
}
fn action_rename(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let dir = loc.path.clone();
let full = join_path(&dir, &name);
set_dialog.set(Some(Dialog::PromptOp {
title: i18n::t_fmt(i18n::k::RENAME_TITLE, &name),
label: i18n::t(i18n::k::NEW_NAME).to_string(),
initial: name.clone(),
submit: i18n::t(i18n::k::RENAME).to_string(),
run: owner.with(|| {
Callback::new(move |new_name: String| {
let full = full.clone();
let (op, dir) = (op.clone(), dir.clone());
let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
api::rename_item(root_id, &full, new_name, false)
.await
.map(|_| ())
.map_err(|e| e.to_string())
match op {
NameOp::Mkdir => api::mkdir(root_id, &join_path(&dir, &name)).await,
NameOp::CreateFile => {
api::create_file(root_id, &join_path(&dir, &name)).await
}
NameOp::Rename(old) => {
api::rename_item(root_id, &join_path(&dir, &old), name, false).await
}
}
.map(|_| ())
.map_err(|e| e.to_string())
});
fut
})
}),
on_ok: owner.with(|| {
Callback::new(move |_| {
show(toast, i18n::t(i18n::k::RENAMED));
show(toast, i18n::t(ok));
refresh.run(());
})
}),
@@ -2528,8 +2334,7 @@ fn upload_prepared(
}
/// Move or copy the entries to a folder chosen in the picker. Several are
/// processed one by one; name collisions are collected and asked about once,
/// and the overwrite retry re-runs only the items that collided.
/// processed one by one; name collisions are collected and asked about once.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn action_move_copy(
op: Op,
@@ -2567,59 +2372,62 @@ fn action_move_copy(
for_write: true,
on_pick: owner.with(|| {
Callback::new(move |(dst_root, dst_dir): (i64, String)| {
// Items still to do. A finished item is dropped, so the
// overwrite retry re-runs only the ones that collided.
let pending = Arc::new(Mutex::new(items.clone()));
run_with_overwrite_retry(
move |overwrite| {
let (pending, dst_dir) = (pending.clone(), dst_dir.clone());
Box::pin(async move {
let items = pending.lock().unwrap().clone();
let mut conflicts = Vec::new();
for (name, full) in items {
let res = if op == Op::Move {
api::move_item(
root_id, &full, dst_root, &dst_dir, overwrite,
)
.await
} else {
api::copy_item(
root_id, &full, dst_root, &dst_dir, overwrite,
)
.await
};
match res {
Ok(_) => {
pending.lock().unwrap().retain(|(_, p)| *p != full)
let wire = match op {
Op::Move => api_types::Op::Move,
Op::Copy => api_types::Op::Copy,
};
let ok_msg = i18n::t(if op == Op::Move {
i18n::k::MOVED
} else {
i18n::k::COPIED
});
// Runs the items and returns the ones that collided.
let run = move |items: Vec<(String, String)>,
overwrite: bool,
dst_dir: String| async move {
let mut conflicts = Vec::new();
for (name, full) in items {
match api::mutation(root_id, &full, wire, dst_root, &dst_dir, overwrite)
.await
{
Ok(_) => {}
Err(e) if is_conflict(&e) => conflicts.push((name, full)),
Err(e) => return Err(e.to_string()),
}
}
Ok(conflicts)
};
let items = items.clone();
spawn_local(async move {
let conflicts = match run(items, false, dst_dir.clone()).await {
Ok(c) if c.is_empty() => {
show(toast, ok_msg);
refresh.run(());
return;
}
Ok(c) => c,
Err(e) => return show_error(toast, e),
};
crate::views::dialogs::claim_async_dialog(dialog).await;
set_dialog.set(Some(Dialog::Conflict {
title: i18n::t(i18n::k::NAME_TAKEN).to_string(),
files: conflicts.iter().map(|(n, _)| n.clone()).collect(),
// The retry re-runs only the items that collided.
on_submit: Callback::new(move |_| {
let (conflicts, dst_dir) = (conflicts.clone(), dst_dir.clone());
spawn_local(async move {
match run(conflicts, true, dst_dir).await {
Ok(c) if c.is_empty() => {
show(toast, ok_msg);
refresh.run(());
}
Err(e) if is_conflict(&e) => conflicts.push(name),
Err(e) => return Err(e),
Ok(_) => show_error(toast, i18n::t(i18n::k::NAME_TAKEN)),
Err(e) => show_error(toast, e),
}
}
if conflicts.is_empty() {
Ok(())
} else {
Err(api::ApiError::Http {
status: 409,
message: String::new(),
skipped: Some(conflicts),
})
}
})
},
i18n::t(i18n::k::NAME_TAKEN),
// Unused: the conflict error above always lists the names.
Vec::new(),
if op == Op::Move {
i18n::t(i18n::k::MOVED)
} else {
i18n::t(i18n::k::COPIED)
},
refresh,
toast,
dialog,
set_dialog,
);
});
}),
}));
});
})
}),
}));
Mweb/src/views/dialogs.rs
@@ -126,7 +126,7 @@ pub enum Dialog {
pub type PromptOpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
/// Split a "/"-separated directory path into its non-empty segments.
fn dir_segs(dir: &str) -> Vec<String> {
pub(crate) fn dir_segs(dir: &str) -> Vec<String> {
dir.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
@@ -150,199 +150,121 @@ pub fn DialogView(
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
// Escape is the <dialog>'s own; see `Modal`.
view! {
{move || {
let Some(d) = dialog.get() else {
return {
view! {}.into_any()
};
};
let close = Callback::new(move |_| set_dialog.set(None));
match &d {
Dialog::PromptOp {
title,
label,
initial,
submit,
run,
on_ok,
} => {
let (title, label, initial, submit, run, on_ok, close) = (
title.clone(),
label.clone(),
initial.clone(),
submit.clone(),
*run,
*on_ok,
close,
);
view! {
<PromptOpDialog
title=title
label=label
initial=initial
submit=submit
run=run
on_ok=on_ok
on_close=close
/>
}
.into_view()
.into_any()
}
Dialog::Confirm {
title,
message,
submit,
danger,
on_submit,
} => {
let (title, message, submit, danger, on_submit, close) = (
title.clone(),
message.clone(),
submit.clone(),
*danger,
*on_submit,
close,
);
view! {
<ConfirmDialog
title=title
message=message
submit=submit
danger=danger
on_submit=on_submit
on_close=close
/>
}
.into_view()
.into_any()
}
Dialog::UploadConflict { files, on_decide } => {
let (files, on_decide) = (files.clone(), *on_decide);
view! {
<UploadConflictDialog files=files on_decide=on_decide on_close=close/>
}
.into_view()
.into_any()
}
Dialog::Conflict {
title,
files,
on_submit,
} => {
let (title, files, on_submit, close) = (
title.clone(),
files.clone(),
*on_submit,
close,
);
view! {
<ConflictDialog
title=title
files=files
on_submit=on_submit
on_close=close
/>
}
.into_view()
.into_any()
}
Dialog::Picker {
title,
confirm,
roots,
root,
dir,
for_write,
on_pick,
} => {
view! {
<PickerDialog
title=title.clone()
confirm=confirm.clone()
roots=roots.clone()
root=*root
dir=dir.clone()
for_write=*for_write
on_pick=*on_pick
on_close=close
/>
}
.into_view()
.into_any()
}
Dialog::DownloadFormat {
name,
root_id,
path,
} => {
let (name, root_id, path) = (
name.clone(),
*root_id,
path.clone(),
);
view! {
<DownloadFormatDialog
name=name
root_id=root_id
path=path
on_close=close
/>
}
.into_view()
.into_any()
}
Dialog::Info {
entry,
root_name,
rel_path,
} => {
let (entry, root_name, rel_path) = (
entry.clone(),
root_name.clone(),
rel_path.clone(),
);
view! {
<InfoDialog
entry=entry
root_name=root_name
rel_path=rel_path
on_close=close
/>
}
.into_view()
.into_any()
}
Dialog::Share {
name,
root_id,
path,
allow_writable,
root_writable,
} => {
let (name, root_id, path, allow_writable, root_writable) = (
name.clone(),
*root_id,
path.clone(),
*allow_writable,
*root_writable,
);
view! {
<crate::views::shares::ShareDialog
name=name
root_id=root_id
path=path
allow_writable=allow_writable
root_writable=root_writable
close=close
/>
}
.into_view()
.into_any()
}
move || {
let d = dialog.get()?;
let close = Callback::new(move |_| set_dialog.set(None));
Some(match d {
Dialog::PromptOp {
title,
label,
initial,
submit,
run,
on_ok,
} => view! {
<PromptOpDialog
title=title
label=label
initial=initial
submit=submit
run=run
on_ok=on_ok
on_close=close
/>
}
.into_any(),
Dialog::Confirm {
title,
message,
submit,
danger,
on_submit,
} => view! {
<ConfirmDialog
title=title
message=message
submit=submit
danger=danger
on_submit=on_submit
on_close=close
/>
}
.into_any(),
Dialog::UploadConflict { files, on_decide } => view! {
<UploadConflictDialog files=files on_decide=on_decide on_close=close/>
}
.into_any(),
Dialog::Conflict {
title,
files,
on_submit,
} => view! {
<ConfirmDialog
title=title
message=i18n::t(i18n::k::OVERWRITE_QUESTION).to_string()
submit=i18n::t(i18n::k::OVERWRITE).to_string()
danger=true
files=files
on_submit=on_submit
on_close=close
/>
}
.into_any(),
Dialog::Picker {
title,
confirm,
roots,
root,
dir,
for_write,
on_pick,
} => view! {
<PickerDialog
title=title
confirm=confirm
roots=roots
root=root
dir=dir
for_write=for_write
on_pick=on_pick
on_close=close
/>
}
.into_any(),
Dialog::DownloadFormat {
name,
root_id,
path,
} => view! {
<DownloadFormatDialog name=name root_id=root_id path=path on_close=close/>
}
.into_any(),
Dialog::Info {
entry,
root_name,
rel_path,
} => view! {
<InfoDialog entry=entry root_name=root_name rel_path=rel_path on_close=close/>
}
.into_any(),
Dialog::Share {
name,
root_id,
path,
allow_writable,
root_writable,
} => view! {
<crate::views::shares::ShareDialog
name=name
root_id=root_id
path=path
allow_writable=allow_writable
root_writable=root_writable
close=close
/>
}
}}
.into_any(),
})
}
}
@@ -403,14 +325,7 @@ fn PromptOpDialog(
}
}
/>
{move || {
let Some(err) = error.get() else {
return view! {}.into_any();
};
view! { <p class="dialog-error">{err}</p> }
.into_view()
.into_any()
}}
{move || error.get().map(|err| view! { <p class="dialog-error">{err}</p> })}
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
{i18n::tr(i18n::k::CANCEL)}
@@ -428,34 +343,35 @@ fn PromptOpDialog(
}
// ---------------------------------------------------------------------------
// Confirm
// Confirm and conflicts
// ---------------------------------------------------------------------------
/// A yes/no question. `files` lists the affected names under the message.
#[component]
fn ConfirmDialog(
pub fn ConfirmDialog(
title: String,
message: String,
submit: String,
danger: bool,
#[prop(optional)] files: Vec<String>,
/// The dismiss button label. Defaults to "Cancel".
#[prop(optional)]
cancel: Option<String>,
on_submit: Callback<()>,
on_close: Callback<()>,
) -> impl IntoView {
let cancel = cancel.unwrap_or_else(|| i18n::t(i18n::k::CANCEL).to_string());
view! {
<Modal class="card" on_close=on_close>
<h2 class="modal-title">{title}</h2>
<p class="modal-message">{message}</p>
{(!files.is_empty()).then(|| conflict_list(&files))}
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
{i18n::tr(i18n::k::CANCEL)}
{cancel}
</button>
<button
class=move || {
if danger {
"btn btn-danger".to_string()
} else {
"btn btn-primary".to_string()
}
}
class=if danger { "btn btn-danger" } else { "btn btn-primary" }
on:click=move |_| {
on_submit.run(());
on_close.run(());
@@ -468,10 +384,6 @@ fn ConfirmDialog(
}
}
// ---------------------------------------------------------------------------
// Conflict (overwrite warning)
// ---------------------------------------------------------------------------
/// The list of colliding files shown by both conflict dialogs. Long lists
/// are cut off with an "and N more" line.
fn conflict_list(files: &[String]) -> impl IntoView + use<> {
@@ -487,38 +399,6 @@ fn conflict_list(files: &[String]) -> impl IntoView + use<> {
}
}
#[component]
fn ConflictDialog(
title: String,
files: Vec<String>,
on_submit: Callback<()>,
on_close: Callback<()>,
) -> impl IntoView {
view! {
<Modal class="card" on_close=on_close>
<h2 class="modal-title">{title}</h2>
<p class="modal-message">
{i18n::tr(i18n::k::OVERWRITE_QUESTION)}
</p>
{conflict_list(&files)}
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
{i18n::tr(i18n::k::CANCEL)}
</button>
<button
class="btn btn-danger"
on:click=move |_| {
on_submit.run(());
on_close.run(());
}
>
{i18n::tr(i18n::k::OVERWRITE)}
</button>
</div>
</Modal>
}
}
/// The upload pre-check dialog: Overwrite, Skip, Cancel, and "apply to all"
/// (on by default) for files that appear during the transfer.
#[component]
@@ -723,7 +603,6 @@ fn PickerDialog(
return view! {
<p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any();
}
if es.is_empty() {
@@ -733,7 +612,6 @@ fn PickerDialog(
<span>{i18n::tr(i18n::k::NO_SUBFOLDERS)}</span>
</div>
}
.into_view()
.into_any();
}
es.iter()
@@ -749,7 +627,6 @@ fn PickerDialog(
}
})
.collect::<Vec<_>>()
.into_view()
.into_any()
}}
</div>
Mweb/src/views/file_view.rs
@@ -11,89 +11,31 @@ use leptos::prelude::*;
use crate::api;
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::editor::{EditTarget, Editor};
use crate::editor::Editor;
use crate::icons::{IconName, icon_for};
use crate::preview::{Preview, PreviewKind, PreviewTarget};
use crate::preview::Preview;
/// A file that is open in the full-page view.
#[derive(Clone)]
pub enum FileView {
/// Image / PDF / video / audio.
Preview(PreviewTarget, PreviewKind),
/// Text file; `EditTarget.readonly` decides whether it is editable.
Editor(EditTarget),
/// No built-in viewer for this kind: show a message + download button.
Unsupported(UnsupportedTarget),
}
/// A file the app cannot preview (binary, archive, ...).
#[derive(Clone)]
pub struct UnsupportedTarget {
pub struct FileView {
pub root_id: i64,
/// Path relative to the root.
/// Path relative to the root (e.g. "docs/a.txt").
pub path: String,
/// Display name.
/// Display name (used for the title + language detection).
pub name: String,
/// Sniffed kind, for the icon.
/// Sniffed kind: picks the viewer and the icon.
pub kind: FileKind,
}
impl FileView {
pub fn name(&self) -> &str {
match self {
FileView::Preview(t, _) => &t.name,
FileView::Editor(t) => &t.name,
FileView::Unsupported(t) => &t.name,
}
}
/// The glyph shown next to the name in the top bar.
pub fn icon(&self) -> IconName {
match self {
FileView::Preview(_t, kind) => match kind {
PreviewKind::Image => IconName::Image,
PreviewKind::Pdf => IconName::Pdf,
PreviewKind::Video => IconName::Video,
PreviewKind::Audio => IconName::Audio,
},
FileView::Editor(t) => icon_for(FileKind::Text, &t.name),
FileView::Unsupported(t) => icon_for(t.kind, &t.name),
}
}
/// Root the file lives in (for the download link).
pub fn root_id(&self) -> i64 {
match self {
FileView::Preview(t, _) => t.root_id,
FileView::Editor(t) => t.root_id,
FileView::Unsupported(t) => t.root_id,
}
}
/// Path relative to the root (for the download link).
pub fn path(&self) -> &str {
match self {
FileView::Preview(t, _) => &t.path,
FileView::Editor(t) => &t.path,
FileView::Unsupported(t) => &t.path,
}
}
/// True when the file is shown read-only (a text file in a read-only
/// root). Media is always "read-only" in the sense that there is no
/// save action; only the editor exposes the badge.
pub fn readonly(&self) -> bool {
matches!(self, FileView::Editor(t) if t.readonly)
}
/// True when the file's folder is read-only. Only the editor acts on it.
pub readonly: bool,
}
/// Top-bar replacement for the brand: file icon + name (with a dirty dot)
/// and, for read-only text files, a badge.
#[component]
pub fn FileViewTitle(view: FileView, dirty: ReadSignal<bool>) -> impl IntoView {
let name = view.name().to_string();
let icon = view.icon();
let readonly = view.readonly();
let icon = icon_for(view.kind, &view.name);
let readonly = view.kind == FileKind::Text && view.readonly;
let name = view.name;
view! {
<div class="file-title">
<Icon name=icon class="file-title-icon".to_string()/>
@@ -103,13 +45,7 @@ pub fn FileViewTitle(view: FileView, dirty: ReadSignal<bool>) -> impl IntoView {
format!("{name}{dot}")
}}
</span>
{move || {
if readonly {
view! { <span class="badge">{i18n::t(i18n::k::READ_ONLY_BADGE)}</span> }.into_view().into_any()
} else {
view! {}.into_any()
}
}}
{readonly.then(|| view! { <span class="badge">{i18n::t(i18n::k::READ_ONLY_BADGE)}</span> })}
</div>
}
}
@@ -144,8 +80,8 @@ pub fn FileViewActions(
title={i18n::tr(i18n::k::DOWNLOAD)}
aria-label={i18n::tr(i18n::k::DOWNLOAD)}
on:click=move |_| {
let url = api::download_url(dl.root_id(), dl.path(), None);
api::trigger_download(&url, dl.name());
let url = api::download_url(dl.root_id, &dl.path, None);
api::trigger_download(&url, &dl.name);
show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
}
>
@@ -155,8 +91,7 @@ pub fn FileViewActions(
};
view! {
<div class="file-actions">
{move || match save_cb.get() {
Some(cb) => view! {
{move || save_cb.get().map(|cb| view! {
<button
class="btn btn-sm btn-primary"
disabled=move || !dirty.get() || saving.get()
@@ -170,11 +105,7 @@ pub fn FileViewActions(
}
}}
</button>
}
.into_view()
.into_any(),
None => view! {}.into_any(),
}}
})}
{download_btn}
<Show when=move || show_close>
<button
@@ -210,64 +141,23 @@ pub fn FileViewContent(
/// Re-fetch the (hidden, still mounted) browser after a save.
refresh: Callback<()>,
) -> impl IntoView {
match view {
FileView::Preview(target, kind) => view! {
<div class="file-view">
<Preview target=target kind=kind/>
</div>
}
.into_view()
.into_any(),
FileView::Editor(target) => view! {
<div class="file-view">
<Editor
target=target
register_save=register_save
register_close=register_close
dirty=dirty
set_dirty=set_dirty
set_saving=set_saving
close=close
toast=toast
refresh=refresh
/>
</div>
}
.into_view()
.into_any(),
FileView::Unsupported(target) => view! {
<div class="file-view">
<NoPreview target=target/>
</div>
let content = if view.kind == FileKind::Text {
view! {
<Editor
target=view
register_save=register_save
register_close=register_close
dirty=dirty
set_dirty=set_dirty
set_saving=set_saving
close=close
toast=toast
refresh=refresh
/>
}
.into_view()
.into_any(),
}
}
/// Shown for files with no built-in viewer: a message and a download button.
#[component]
fn NoPreview(target: UnsupportedTarget) -> impl IntoView {
let icon = icon_for(target.kind, &target.name);
let name = target.name.clone();
let t = target;
view! {
<div class="file-nopreview">
<div class="empty-state">
<Icon name=icon class="empty-glyph".to_string()/>
<h3>{i18n::tr(i18n::k::NO_PREVIEW)}</h3>
<p class="muted">{i18n::t_fmt(i18n::k::NO_PREVIEW_MSG, &name)}</p>
<button
class="btn btn-primary"
on:click=move |_| {
let url = api::download_url(t.root_id, &t.path, None);
api::trigger_download(&url, &name);
}
>
<Icon name=IconName::Download class="ic-btn".to_string()/>
{i18n::tr(i18n::k::DOWNLOAD)}
</button>
</div>
</div>
}
.into_any()
} else {
view! { <Preview target=view/> }.into_any()
};
view! { <div class="file-view">{content}</div> }
}
Mweb/src/views/login.rs
@@ -68,19 +68,8 @@ pub fn LoginView(
// field is not on screen and not needed.
let (name, state_id) = match step.get() {
Step::NeedPassword(p) => (None, Some(p.state_id)),
_ => {
let n = input_value("login-name").trim().to_string();
if n.is_empty() {
set_error.set(i18n::t(i18n::k::LOGIN_ERROR).into());
return;
}
(Some(n), None)
}
_ => (Some(input_value("login-name").trim().to_string()), None),
};
if pass.is_empty() {
set_error.set(i18n::t(i18n::k::LOGIN_ERROR).into());
return;
}
set_error.set(String::new());
set_busy.set(true);
spawn_local(async move {
@@ -168,6 +157,7 @@ pub fn LoginView(
<input
id="login-name"
type="text"
required=true
autofocus=true
// `webauthn` is what puts passkeys into this
// field's autofill dropdown.
@@ -181,6 +171,7 @@ pub fn LoginView(
<input
id="login-pass"
type="password"
required=true
autocomplete="current-password"
/>
</label>
Mweb/src/views/search.rs
@@ -21,14 +21,12 @@ use wasm_bindgen::JsCast;
use crate::api;
use crate::components::icon::{Icon, icon_svg};
use crate::components::toast::{ToastMsg, show_error};
use crate::editor::EditTarget;
use crate::i18n::{self, k};
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{self, Location, navigate};
use crate::util::{FILE_PAGE, format_size, page_footer};
use crate::views::dialogs::Dialog;
use crate::views::file_view::{FileView, UnsupportedTarget};
use crate::views::dialogs::{Dialog, dir_segs};
use crate::views::file_view::FileView;
/// Name hits kept for paging. The search keeps running and counting past
/// this; hits beyond it are reported as a plain remainder that cannot be
@@ -325,7 +323,7 @@ fn scroll_to_y(y: f64) {
/// Navigate to the folder `path` sits in, so the hit can be seen in context.
/// A top-level entry goes to the root itself.
fn goto_parent(root_id: i64, path: &str) {
let mut parts = split_rel(path);
let mut parts = dir_segs(path);
parts.pop();
navigate(&Location::folder(Some(root_id), None, parts));
}
@@ -650,14 +648,13 @@ pub fn SearchView(
}
match found {
Ok(Some(e)) if !e.is_dir => {
let fv = open_file_view(
root,
path.clone(),
e.name,
e.kind,
is_writable(me, root),
);
open_file.set(Some(fv));
open_file.set(Some(FileView {
root_id: root,
path: path.clone(),
name: e.name,
kind: e.kind,
readonly: !is_writable(me, root),
}));
}
Ok(_) => {
show_error(toast, i18n::t(k::FILE_NOT_FOUND).to_string());
@@ -759,18 +756,18 @@ pub fn SearchView(
return;
};
if hit.is_dir {
navigate(&Location::folder(Some(root_id), None, split_rel(&path)));
navigate(&Location::folder(Some(root_id), None, dir_segs(&path)));
return;
}
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
saved_scroll.set_value(scroll_y());
open_file.set(Some(open_file_view(
open_file.set(Some(FileView {
root_id,
path,
name,
hit.kind,
is_writable(me, root_id),
)));
kind: hit.kind,
readonly: !is_writable(me, root_id),
}));
};
// ---- render ------------------------------------------------------------
@@ -790,37 +787,22 @@ pub fn SearchView(
type="text"
placeholder=move || i18n::t(k::SEARCH_PLACEHOLDER).to_string()
prop:value=move || query.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_query.set(t.value());
}
}
on:input=move |ev| set_query.set(event_target_value(&ev))
on:keydown=on_query_key
autocomplete="off"
/>
</div>
<div class="seg">
<button
class:on=move || scope.get() == Scope::Name
on:click=move |_| set_scope.set(Scope::Name)
>
{i18n::tr(k::SEARCH_NAME)}
</button>
<button
class:on=move || scope.get() == Scope::Content
on:click=move |_| set_scope.set(Scope::Content)
>
{i18n::tr(k::SEARCH_CONTENT)}
</button>
<button
class:on=move || scope.get() == Scope::Both
on:click=move |_| set_scope.set(Scope::Both)
>
{i18n::tr(k::SEARCH_BOTH)}
</button>
{[
(Scope::Name, k::SEARCH_NAME),
(Scope::Content, k::SEARCH_CONTENT),
(Scope::Both, k::SEARCH_BOTH),
]
.map(|(s, key)| view! {
<button class:on=move || scope.get() == s on:click=move |_| set_scope.set(s)>
{i18n::tr(key)}
</button>
})}
</div>
// A search covers one folder (and everything below it). The
// button shows it as "Root/dir" and opens the folder picker.
@@ -852,7 +834,6 @@ pub fn SearchView(
{i18n::t(k::SEARCH_STOP)}
</button>
}
.into_view()
.into_any()
} else {
view! {
@@ -860,14 +841,13 @@ pub fn SearchView(
{i18n::t(k::SEARCH_RUN)}
</button>
}
.into_view()
.into_any()
}
}}
</div>
{move || match status.get() {
Status::Idle => view! {}.into_view().into_any(),
Status::Idle => view! {}.into_any(),
Status::Searching => view! {
<div class="status-line">
<span class="dot"></span>
@@ -881,7 +861,6 @@ pub fn SearchView(
</span>
</div>
}
.into_view()
.into_any(),
Status::Done { stopped, summary } => {
let n = (files_total.get() + matches_total.get()).to_string();
@@ -920,7 +899,6 @@ pub fn SearchView(
</span>
</div>
}
.into_view()
.into_any()
}
}}
@@ -1079,7 +1057,7 @@ pub fn SearchView(
// an empty card rather than panic the whole view if
// that ordering ever changes.
let Some(st) = registry.with_untracked(|m| m.get(&f.path).copied()) else {
return view! {}.into_view().into_any();
return view! {}.into_any();
};
let lines_r = st.lines;
let collapsed = st.collapsed;
@@ -1184,7 +1162,6 @@ pub fn SearchView(
</div>
</div>
}
.into_view()
.into_any()
}
/>
@@ -1202,15 +1179,8 @@ pub fn SearchView(
{move || {
let done = matches!(status.get(), Status::Done { .. });
if done && files_total.get() == 0 && matches_total.get() == 0 {
view! {
<div class="search-empty">{i18n::t(k::SEARCH_NO_RESULTS)}</div>
}
.into_view()
.into_any()
} else {
view! {}.into_view().into_any()
}
(done && files_total.get() == 0 && matches_total.get() == 0)
.then(|| view! { <div class="search-empty">{i18n::t(k::SEARCH_NO_RESULTS)}</div> })
}}
</div>
}
@@ -1319,8 +1289,6 @@ async fn apply_batch(ctx: &FlushCtx, batch: Vec<SearchEvent>, my_gen: u64) {
match ev {
SearchEvent::Done {
stopped,
files: _,
matches: _,
scanned,
skipped,
elapsed_ms,
@@ -1475,39 +1443,6 @@ fn is_writable(me: ReadSignal<Option<api_types::Me>>, root_id: i64) -> bool {
})
}
fn open_file_view(
root_id: i64,
path: String,
name: String,
kind: FileKind,
is_rw: bool,
) -> FileView {
if kind == FileKind::Text {
return FileView::Editor(EditTarget {
root_id,
path,
name,
readonly: !is_rw,
});
}
match preview_kind(kind) {
Some(pk) => FileView::Preview(
PreviewTarget {
root_id,
path,
name,
},
pk,
),
None => FileView::Unsupported(UnsupportedTarget {
root_id,
path,
name,
kind,
}),
}
}
/// Open a content match: always a text file (the grep only reads those). One
/// callback per card, shared by all of its lines. Free function rather than a
/// component closure so the card's `<For>` children closure only captures
@@ -1521,14 +1456,13 @@ fn open_match_cb(
) -> Callback<()> {
Callback::new(move |_| {
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
let fv = open_file_view(
open_file.set(Some(FileView {
root_id,
path.to_string(),
path: path.to_string(),
name,
FileKind::Text,
is_writable(me, root_id),
);
open_file.set(Some(fv));
kind: FileKind::Text,
readonly: !is_writable(me, root_id),
}));
})
}
@@ -1537,13 +1471,6 @@ fn query_words(query: &str) -> Vec<String> {
query.split_whitespace().map(lower).collect()
}
fn split_rel(path: &str) -> Vec<String> {
path.split('/')
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
}
/// `("docs/notes", "runbook.md")` from a relative path.
fn split_name(path: &str) -> (&str, &str) {
match path.rfind('/') {
Mweb/src/views/security.rs
@@ -15,7 +15,7 @@ use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
use crate::icons::IconName;
use crate::util::{copy_to_clipboard, select_input, select_value};
use crate::util::copy_to_clipboard;
/// Trim an RFC 3339 timestamp to its date. The exact minute a passkey was
/// registered is noise in a list.
@@ -230,12 +230,7 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
view! {
<div class="security-tab">
{move || {
let Some(e) = error.get() else {
return view! {}.into_any();
};
view! { <p class="dialog-error">{e}</p> }.into_view().into_any()
}}
{move || error.get().map(|e| view! { <p class="dialog-error">{e}</p> })}
<h3 class="setting-label">{i18n::tr(i18n::k::PASSWORD)}</h3>
<Show when=move || !has_password()>
@@ -269,7 +264,7 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
<select
prop:value=move || mode().as_str()
on:change=move |ev: web_sys::Event| {
if let Some(m) = select_value(&ev).and_then(|v| AuthMode::from_wire(&v)) {
if let Some(m) = AuthMode::from_wire(&event_target_value(&ev)) {
set_mode_draft.set(Some(m));
}
}
@@ -313,18 +308,30 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
<div class="passkey-list">
{move || match passkeys.get() {
None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
.into_view()
.into_any(),
Some(list) if list.is_empty() => {
view! { <p class="muted">{i18n::tr(i18n::k::NO_PASSKEYS)}</p> }
.into_view()
.into_any()
}
Some(list) => list
.into_iter()
.map(|p| view! { <PasskeyRow info=p on_remove=remove_passkey/> })
.map(|p| {
// Only `Some(false)` earns a warning: `None` means
// the browser did not say.
let warn = p.discoverable == Some(false);
view! {
<CredentialRow
icon=IconName::User
id=p.id
name=p.name
created_at=p.created_at
last_used_at=p.last_used_at
warn=warn
on_remove=remove_passkey
/>
}
})
.collect::<Vec<_>>()
.into_view()
.into_any(),
}}
</div>
@@ -347,35 +354,38 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
<div class="passkey-list">
{move || match app_passwords.get() {
None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
.into_view()
.into_any(),
Some(list) if list.is_empty() => {
view! { <p class="muted">{i18n::tr(i18n::k::NO_APP_PASSWORDS)}</p> }
.into_view()
.into_any()
}
Some(list) => list
.into_iter()
.map(|p| {
view! { <AppPasswordRow info=p on_remove=remove_app_password/> }
.map(|p| view! {
<CredentialRow
icon=IconName::Share
id=p.id
name=p.name
created_at=p.created_at
last_used_at=p.last_used_at
warn=false
on_remove=remove_app_password
/>
})
.collect::<Vec<_>>()
.into_view()
.into_any(),
}}
</div>
{move || {
let Some(value) = secret.get() else {
return view! {}.into_any();
};
let value = secret.get()?;
let copy = value.clone();
view! {
Some(view! {
<div class="share-link-row">
<input
class="share-link-input"
readonly=true
value=value
on:click=|ev| select_input(&ev)
on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
/>
<button
class="btn"
@@ -390,9 +400,7 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
</button>
</div>
<p class="setting-desc">{i18n::tr(i18n::k::APP_PASSWORD_SECRET_ONCE)}</p>
}
.into_view()
.into_any()
})
}}
<div class="security-form">
<label class="field">
@@ -410,65 +418,36 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
}
}
/// One app password in the list. No warning row, unlike a passkey: nothing
/// about one can be half-working.
/// One passkey or app password in the list. `warn` marks a passkey that
/// the browser reported as not discoverable.
#[component]
fn AppPasswordRow(info: AppPasswordInfo, on_remove: Callback<i64>) -> impl IntoView {
let id = info.id;
let used = match &info.last_used_at {
fn CredentialRow(
icon: IconName,
id: i64,
name: String,
created_at: String,
last_used_at: Option<String>,
warn: bool,
on_remove: Callback<i64>,
) -> impl IntoView {
let used = match &last_used_at {
Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &day(t)),
None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(),
};
view! {
<div class="passkey-row">
<Icon name=IconName::Share class="ic-row".to_string()/>
<Icon name=icon class="ic-row".to_string()/>
<span class="passkey-main">
<span class="passkey-name">{info.name.clone()}</span>
<span class="passkey-name">{name}</span>
<span class="setting-desc">
{i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &day(&info.created_at))}
{i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &day(&created_at))}
" · " {used}
</span>
</span>
<button
class="icon-btn"
title=i18n::t(i18n::k::DELETE)
aria-label=i18n::t(i18n::k::DELETE)
on:click=move |_| on_remove.run(id)
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
</div>
}
}
/// One passkey in the list.
///
/// `discoverable == Some(false)` is the only case that earns a warning. The
/// browser reports this through an optional, unsigned extension, so `None`
/// means it did not say, and guessing "not discoverable" there would put a
/// scary label on a passkey that works perfectly.
#[component]
fn PasskeyRow(info: PasskeyInfo, on_remove: Callback<i64>) -> impl IntoView {
let id = info.id;
let used = match &info.last_used_at {
Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &day(t)),
None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(),
};
let needs_name = info.discoverable == Some(false);
view! {
<div class="passkey-row">
<Icon name=IconName::User class="ic-row".to_string()/>
<span class="passkey-main">
<span class="passkey-name">{info.name.clone()}</span>
<span class="setting-desc">
{i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &day(&info.created_at))}
" · " {used}
</span>
<Show when=move || needs_name>
{warn.then(|| view! {
<span class="passkey-warning" title=i18n::t(i18n::k::PASSKEY_NEEDS_NAME_HINT)>
{i18n::tr(i18n::k::PASSKEY_NEEDS_NAME)}
</span>
</Show>
})}
</span>
<button
class="icon-btn"
Mweb/src/views/setup.rs
@@ -22,14 +22,7 @@ pub fn SetupView(
let confirm = input_value("setup-confirm");
let name = name.trim();
if name.is_empty() || name.len() > 64 {
set_error.set(i18n::t(i18n::k::SETUP_NAME_ERR).into());
return;
}
if pass.len() < 8 {
set_error.set(i18n::t(i18n::k::PW_SHORT).into());
return;
}
// The inputs check the name and the password length.
if pass != confirm {
set_error.set(i18n::t(i18n::k::SETUP_PW_MISMATCH).into());
return;
@@ -71,6 +64,8 @@ pub fn SetupView(
<input
id="setup-name"
type="text"
required=true
maxlength="64"
autofocus=true
autocomplete="username"
/>
@@ -80,6 +75,8 @@ pub fn SetupView(
<input
id="setup-pass"
type="password"
required=true
minlength="8"
autocomplete="new-password"
/>
</label>
Mweb/src/views/shell.rs
@@ -100,23 +100,15 @@ pub fn ShellView(
// (deep link, hand-typed, or left over from a demotion), redirect to the
// files view so the URL, the content fallback, and the sidebar highlight
// all agree.
{
let me2 = me;
let loc2 = loc;
Effect::new(move |_| {
let Some(m) = me2.get() else {
return;
};
let admin = m.user.as_ref().is_some_and(|u| u.is_admin);
if admin {
return;
}
let section = loc2.get().section;
if section == Section::Users {
navigate(&Location::root());
}
});
}
Effect::new(move |_| {
let Some(m) = me.get() else {
return;
};
let admin = m.user.as_ref().is_some_and(|u| u.is_admin);
if !admin && loc.get().section == Section::Users {
navigate(&Location::root());
}
});
// Open the profile's default root once per page load. The guards keep
// this from looping: a stale default that 404s refreshes `/me`, which
@@ -179,38 +171,20 @@ pub fn ShellView(
{topbar_right}
</div>
</header>
{move || {
if nav_open.get() {
view! {
<div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
<Show when=move || nav_open.get()>
<div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
</Show>
<div class="body-row">
{move || {
if file_view.get().is_none() {
view! {
file_view.get().is_none().then(|| view! {
<aside
class="sidebar"
class:open=move || nav_open.get()
>
<nav class="nav">
{move || {
let Some(u) = me.get().and_then(|m| m.user) else {
return {
view! {}.into_any()
};
};
view! {
<div class="sidebar-user">{u.name.clone()}</div>
}
.into_view()
.into_any()
}}
{move || me.get().and_then(|m| m.user).map(|u| view! {
<div class="sidebar-user">{u.name}</div>
})}
<div class="nav-section">
<div class="nav-section-head">
<div class="nav-section-label">{i18n::tr(i18n::k::FILES)}</div>
@@ -231,12 +205,8 @@ pub fn ShellView(
<span class="nav-tab-name">{i18n::tr(i18n::k::SEARCH)}</span>
</button>
{move || {
let Some(m) = me.get() else {
return {
view! {}.into_any()
};
};
m.roots
let m = me.get()?;
Some(m.roots
.iter()
.map(|r| {
let id = r.id;
@@ -269,59 +239,33 @@ pub fn ShellView(
>
<Icon name=IconName::Folder class="ic-tab".to_string()/>
<span class="nav-tab-name">{name}</span>
{move || {
if ro {
view! { <span class="nav-tab-note">"ro"</span> }
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
{ro.then(|| view! { <span class="nav-tab-note">"ro"</span> })}
</button>
}
})
.collect::<Vec<_>>()
.into_view()
.into_any()
.collect::<Vec<_>>())
}}
</div>
<div class="nav-section">
<div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
<button
class="nav-tab"
class:active=move || section.get() == Section::Shares
on:click=move |_| open_section(Section::Shares)
>
<Icon name=IconName::Share class="ic-tab".to_string()/>
<span class="nav-tab-name">{i18n::tr(i18n::k::SHARES)}</span>
</button>
<Show when=move || admin.get()>
<button
class="nav-tab"
class:active=move || section.get() == Section::Users
on:click=move |_| open_section(Section::Users)
>
<Icon name=IconName::User class="ic-tab".to_string()/>
<span class="nav-tab-name">{i18n::tr(i18n::k::USERS)}</span>
</button>
</Show>
<button
class="nav-tab"
class:active=move || section.get() == Section::Uploads
on:click=move |_| open_section(Section::Uploads)
>
<Icon name=IconName::Upload class="ic-tab".to_string()/>
<span class="nav-tab-name">{i18n::tr(i18n::k::UPLOADS)}</span>
</button>
<button
class="nav-tab"
class:active=move || section.get() == Section::Settings
on:click=move |_| open_section(Section::Settings)
>
<Icon name=IconName::Settings class="ic-tab".to_string()/>
<span class="nav-tab-name">{i18n::tr(i18n::k::SETTINGS)}</span>
</button>
{[
(Section::Shares, IconName::Share, i18n::k::SHARES),
(Section::Users, IconName::User, i18n::k::USERS),
(Section::Uploads, IconName::Upload, i18n::k::UPLOADS),
(Section::Settings, IconName::Settings, i18n::k::SETTINGS),
]
.map(|(sec, icon, label)| view! {
<Show when=move || sec != Section::Users || admin.get()>
<button
class="nav-tab"
class:active=move || section.get() == sec
on:click=move |_| open_section(sec)
>
<Icon name=icon class="ic-tab".to_string()/>
<span class="nav-tab-name">{i18n::tr(label)}</span>
</button>
</Show>
})}
</div>
</nav>
<div class="sidebar-footer">
@@ -355,17 +299,17 @@ pub fn ShellView(
</div>
</div>
</aside>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
})
}}
<main class="content" class:file-open=move || file_view.get().is_some()>
{move || {
match section.get() {
Section::Files => view! {
Section::Users if admin.get() => {
view! { <UsersView me=me set_me=set_me set_dialog=set_dialog/> }.into_any()
}
// Users without admin (e.g. permissions changed)
// falls back to the files view.
Section::Files | Section::Users => view! {
<Browser
me=me
set_me=set_me
@@ -379,7 +323,6 @@ pub fn ShellView(
file_view=file_view
/>
}
.into_view()
.into_any(),
Section::Search => view! {
<SearchView
@@ -390,38 +333,14 @@ pub fn ShellView(
set_dialog=set_dialog
/>
}
.into_view()
.into_any(),
Section::Shares => view! { <SharesView me=me/> }
.into_view()
.into_any(),
Section::Users if admin.get() => {
view! { <UsersView me=me set_me=set_me set_dialog=set_dialog/> }.into_view().into_any()
}
Section::Settings => view! {
<SettingsView me=me loc=loc set_me=set_me set_dialog=set_dialog/>
}
.into_view()
.into_any(),
Section::Uploads => view! { <UploadsView/> }.into_view().into_any(),
// Not an admin anymore (e.g. permissions changed):
// fall back to the files view.
_ => view! {
<Browser
me=me
set_me=set_me
loc=loc
open_file=open_file
set_browser_refresh=set_browser_refresh
selected=selected
set_selected=set_selected
set_dialog=set_dialog
dialog=dialog
file_view=file_view
/>
}
.into_view()
.into_any(),
Section::Uploads => view! { <UploadsView/> }.into_any(),
}
}}
{file_content}
Mweb/src/views/workspace.rs
@@ -225,21 +225,16 @@ impl FileWorkspace {
// there is nothing to close back to (file share). The editor handles
// Escape itself (it asks first when there are unsaved changes).
{
let fv = file_view;
let cf = close_file;
let ct = close_target.clone();
owned_window_listener(keydown, move |ev: KeyboardEvent| {
if ev.key() != "Escape" {
if ev.key() != "Escape" || ct().is_some() {
return;
}
if ct().is_some() {
return;
}
if fv
if file_view
.get()
.is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
.is_some_and(|v| v.kind != api_types::FileKind::Text)
{
cf.run(());
close_file.run(());
}
});
}
@@ -301,7 +296,6 @@ impl FileWorkspace {
</a>
<FileViewTitle view=v.clone() dirty=dirty />
}
.into_view()
.into_any(),
None => view! {
<a class="brand" href=home>
@@ -309,7 +303,6 @@ impl FileWorkspace {
<span class="brand-name">"filebrowser-ng"</span>
</a>
}
.into_view()
.into_any(),
}}
}
@@ -351,7 +344,6 @@ impl FileWorkspace {
toast=toast
/>
}
.into_view()
.into_any()
} else if show_actions() {
let refresh = browser_refresh
@@ -371,7 +363,6 @@ impl FileWorkspace {
topbar_ref=topbar_ref
/>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
@@ -393,8 +384,7 @@ impl FileWorkspace {
let toast = self.toast;
let browser_refresh = self.browser_refresh;
view! {
{move || match file_view.get() {
Some(v) => {
{move || file_view.get().map(|v| {
let refresh = browser_refresh
.get()
.unwrap_or_else(|| Callback::new(move |_| {}));
@@ -411,11 +401,7 @@ impl FileWorkspace {
refresh=refresh
/>
}
.into_view()
.into_any()
}
None => view! {}.into_any(),
}}
})}
}
}
@@ -436,7 +422,7 @@ impl FileWorkspace {
/// on the search view by its path in the root. `None` for a file outside
/// the folder, which the URL cannot name.
fn open_param(loc: &Location, v: &FileView) -> Option<String> {
let path = v.path();
let path = v.path.as_str();
if loc.section == Section::Search {
return Some(path.to_string());
}