Lint: cargo fmt + clippy clean (0 warnings, -D warnings enforced)
- Ran cargo fmt across the workspace (was never formatted).
- Server: fixed 20 clippy warnings (collapsible str::replace, needless
lifetimes, sort_by_key, LazyLock consts -> static, bool asserts,
needless borrows).
- Upload rename: the '.map_err(|e| e)?' chain was convoluted; split into
two explicit ? steps (join error, then io error with tmp cleanup) —
same behavior, no dead map_err.
- Web: fixed 90 clippy warnings (clone on Copy Callback/signals,
i32->i32 casts, redundant closures -> fn pointers, let-unit bindings,
needless borrows, let-else -> ?, removed redundant Copy shadows).
- Documented allows: clippy::unit_arg/unused_unit (Leptos 'view!{}'
expands to a unit expression) and per-fn too_many_arguments on
signal-prop Leptos components.
- Adds 'just lint' (fmt --check + clippy -D warnings).
Co-Authored-By: Qwen3.8 27bMjustfile
@@ -41,6 +41,11 @@ run: build
test:
cargo test -p server
# Lint: formatting check + clippy with warnings denied.
lint:
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
# Coverage report (requires `cargo install cargo-llvm-cov` + `rustup component add llvm-tools`).
cov:
cargo llvm-cov -p server
Mserver/src/api/admin.rs
@@ -120,10 +120,7 @@ async fn validate_roots(
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?
.map_err(|e| {
ApiError::new(
StatusCode::BAD_REQUEST,
format!("root path '{path}': {e}"),
)
ApiError::new(StatusCode::BAD_REQUEST, format!("root path '{path}': {e}"))
})?;
out.push((path, r.mode.clone()));
}
@@ -185,8 +182,12 @@ pub async fn create_user(
}
let roots = validate_roots(&state, &body.roots).await?;
let pass_hash = auth::hash_password(&body.password)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}")))?;
let pass_hash = auth::hash_password(&body.password).map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("hashing failed: {e}"),
)
})?;
let user = state
.db
.create_user(&name, &pass_hash, body.is_admin, &roots)
@@ -224,13 +225,9 @@ pub async fn update_user(
}
}
// Never allow dropping to zero active admins.
let demoting = id != admin.user.id
&& body.is_admin == Some(false)
&& target.is_admin;
let disabling = id != admin.user.id
&& body.active == Some(false)
&& target.active
&& target.is_admin;
let demoting = id != admin.user.id && body.is_admin == Some(false) && target.is_admin;
let disabling =
id != admin.user.id && body.active == Some(false) && target.active && target.is_admin;
if (demoting || disabling) && state.db.count_admins().await <= 1 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
@@ -240,8 +237,12 @@ pub async fn update_user(
if let Some(pw) = &body.password {
validate_password(pw)?;
let hash = auth::hash_password(pw)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}")))?;
let hash = auth::hash_password(pw).map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("hashing failed: {e}"),
)
})?;
state.db.update_user_password(id, &hash).await?;
}
if let Some(is_admin) = body.is_admin {
Mserver/src/api/auth.rs
@@ -35,10 +35,7 @@ pub async fn me(
}
let Some(token) = parse_session_cookie(&headers) else {
return Err(ApiError::new(
StatusCode::UNAUTHORIZED,
"not signed in",
));
return Err(ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"));
};
let Some(user) = state.db.session_user(&token).await else {
return Err(ApiError::new(
@@ -113,16 +110,22 @@ pub async fn setup(
));
}
let pass_hash = auth::hash_password(&body.password)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}")))?;
let pass_hash = auth::hash_password(&body.password).map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("hashing failed: {e}"),
)
})?;
let user = state.db.create_admin(name, &pass_hash).await?;
let token = auth::random_token();
state.db.create_session(user.id, &token).await?;
let mut res = Json(serde_json::json!({ "ok": true })).into_response();
res.headers_mut()
.insert(header::SET_COOKIE, session_cookie(&token, state.https).parse().unwrap());
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, state.https).parse().unwrap(),
);
Ok(res)
}
@@ -142,21 +145,22 @@ pub async fn login(
state.db.create_session(user.id, &token).await?;
let mut res = Json(serde_json::json!({ "ok": true })).into_response();
res.headers_mut()
.insert(header::SET_COOKIE, session_cookie(&token, state.https).parse().unwrap());
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, state.https).parse().unwrap(),
);
Ok(res)
}
/// POST /api/auth/logout
pub async fn logout(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Response {
pub async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
if let Some(token) = parse_session_cookie(&headers) {
let _ = state.db.delete_session(&token).await;
}
let mut res = Json(serde_json::json!({ "ok": true })).into_response();
res.headers_mut()
.insert(header::SET_COOKIE, clear_session_cookie(state.https).parse().unwrap());
res.headers_mut().insert(
header::SET_COOKIE,
clear_session_cookie(state.https).parse().unwrap(),
);
res
}
Mserver/src/api/common.rs
@@ -53,14 +53,8 @@ where
share: Some(share),
})
}
Some(_) => Err(ApiError::new(
StatusCode::GONE,
"this share has expired",
)),
None => Err(ApiError::new(
StatusCode::NOT_FOUND,
"share not found",
)),
Some(_) => Err(ApiError::new(StatusCode::GONE, "this share has expired")),
None => Err(ApiError::new(StatusCode::NOT_FOUND, "share not found")),
};
}
@@ -139,8 +133,6 @@ where
if !auth.user.is_admin {
return Err(ApiError::new(StatusCode::FORBIDDEN, "admin only"));
}
Ok(AdminUser {
user: auth.user,
})
Ok(AdminUser { user: auth.user })
}
}
Mserver/src/api/files.rs
@@ -18,12 +18,12 @@ use axum::extract::{Path as AxumPath, Query as AxumQuery, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use multer::Multipart;
use futures_util::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
use multer::Multipart;
use serde::Deserialize;
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use crate::api::common::AuthUser;
use crate::archive::{self, ArchiveFormat};
@@ -118,9 +118,10 @@ async fn list_inner(
let root = find_root(&auth.roots, root_id)?;
let server_root = state.root.clone();
let root_rel = root.path.clone();
let full = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
let full =
tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
.await
@@ -137,8 +138,7 @@ async fn list_inner(
fn disp_name(name: &str) -> String {
name.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "_")
.replace('\r', "_")
.replace(['\n', '\r'], "_")
}
/// Resolve the requested item to an absolute path + metadata (blocking).
@@ -202,7 +202,12 @@ async fn download(
.header(header::CONTENT_TYPE, fmt.mime())
.header(header::CONTENT_DISPOSITION, disp)
.body(body)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("bad response: {e}")))
.map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("bad response: {e}"),
)
})
}
/// `GET ...?action=preview` — a single file, inline (for native media).
@@ -318,7 +323,9 @@ async fn file_response(
size: u64,
inline: bool,
) -> Result<Response, ApiError> {
let mime = mime_guess::from_path(full).first_or_octet_stream().to_string();
let mime = mime_guess::from_path(full)
.first_or_octet_stream()
.to_string();
let disp = if inline {
format!("inline; filename=\"{}\"", disp_name(name))
} else {
@@ -331,7 +338,12 @@ async fn file_response(
.header(header::CONTENT_DISPOSITION, disp)
.header(header::CONTENT_LENGTH, size)
.body(body)
.map_err(|e| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("bad response: {e}")))
.map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("bad response: {e}"),
)
})
}
/// Stream `path` to the client in chunks (blocking reader → channel).
@@ -367,11 +379,7 @@ fn stream_file(path: std::path::PathBuf) -> axum::body::Body {
}
/// 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 {
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);
@@ -525,21 +533,24 @@ async fn mutation(
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "new_name is required"))?
.to_string();
let root = require_rw_root(&auth.roots, root_id)?;
let (server_root, root_rel, rel, overwrite) =
(state.root.clone(), root.path.clone(), req_rel, body.overwrite);
tokio::task::spawn_blocking(move || fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
let (server_root, root_rel, rel, overwrite) = (
state.root.clone(),
root.path.clone(),
req_rel,
body.overwrite,
);
tokio::task::spawn_blocking(move || {
fs::rename_item(&server_root, &root_rel, &rel, &new_name, overwrite)
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true })))
}
"move" | "copy" => {
let dst_root_id = body
.dst_root_id
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
let dst = body
.dst
.clone()
.unwrap_or_default();
let dst = body.dst.clone().unwrap_or_default();
// Moving or copying out of a folder requires rw there; copying
// *from* a read-only root is fine.
let src_root = if body.op == "move" {
@@ -593,9 +604,10 @@ pub async fn delete(
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
let is_dir = tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
let is_dir =
tokio::task::spawn_blocking(move || fs::remove_item(&server_root, &root_rel, &rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true, "is_dir": is_dir })))
}
@@ -658,7 +670,9 @@ async fn upload(
let p = parent.to_path_buf();
tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
.map_err(|_| {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
})??;
}
if target.exists() && !overwrite {
@@ -667,7 +681,8 @@ async fn upload(
.chunk()
.await
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid upload data"))?
{}
{
}
skipped.push(part_name);
continue;
}
@@ -714,15 +729,14 @@ async fn upload(
}
let tmp2 = tmp.clone();
let target2 = target.clone();
tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))
.map_err(|e| e)?
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
tracing::warn!(error = %e, "rename failed during upload");
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
})?;
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
renamed.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
tracing::warn!(error = %e, "rename failed during upload");
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
})?;
uploaded += 1;
}
@@ -734,14 +748,13 @@ async fn upload(
}
if !skipped.is_empty() {
return Err(
ApiError::new(
StatusCode::CONFLICT,
"some files already exist",
)
.with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })),
ApiError::new(StatusCode::CONFLICT, "some files already exist")
.with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })),
);
}
Ok(Json(serde_json::json!({ "ok": true, "uploaded": uploaded })))
Ok(Json(
serde_json::json!({ "ok": true, "uploaded": uploaded }),
))
}
fn parse_boundary(content_type: &str) -> Option<String> {
@@ -781,14 +794,14 @@ fn validate_rel_path(name: &str) -> Result<(), ApiError> {
// Helpers
// ---------------------------------------------------------------------------
fn find_root<'a>(roots: &'a [RootRow], root_id: i64) -> Result<&'a RootRow, ApiError> {
fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
roots
.iter()
.find(|r| r.id == root_id)
.ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))
}
fn require_rw_root<'a>(roots: &'a [RootRow], root_id: i64) -> Result<&'a RootRow, ApiError> {
fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
let root = find_root(roots, root_id)?;
if root.mode != "rw" {
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder"));
Mserver/src/api/spa.rs
@@ -34,18 +34,20 @@ pub(super) async fn fallback(method: Method, uri: Uri) -> Response {
};
match asset {
Some((bytes, mime, cache)) => {
(
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
Some((bytes, mime, cache)) => (
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
bytes,
)
.into_response(),
None => match crate::assets::get_asset("index.html") {
Some((bytes, mime, _)) => (
[
(header::CONTENT_TYPE, mime),
(header::CACHE_CONTROL, "no-cache".to_string()),
],
bytes,
)
.into_response()
}
None => match crate::assets::get_asset("index.html") {
Some((bytes, mime, _)) => {
([(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache".to_string())], bytes)
.into_response()
}
.into_response(),
None => DEV_HINT.into_response(),
},
}
Mserver/src/archive.rs
@@ -95,7 +95,7 @@ fn walk<F: FnMut(&str, &Path, bool) -> io::Result<()>>(
let is_dir = p.is_dir();
children.push((name, p, is_dir));
}
children.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
children.sort_by_key(|c| c.0.to_lowercase());
for (name, p, is_dir) in children {
let child = format!("{entry_prefix}/{name}");
if is_dir {
@@ -218,7 +218,11 @@ mod tests {
std::fs::create_dir_all(dir.join("sub/deep")).unwrap();
std::fs::create_dir(dir.join("empty-dir")).unwrap();
std::fs::write(dir.join("sub/beta.txt"), "beta").unwrap();
std::fs::write(dir.join("sub/deep/gamma.bin"), (0u8..=255).collect::<Vec<_>>()).unwrap();
std::fs::write(
dir.join("sub/deep/gamma.bin"),
(0u8..=255).collect::<Vec<_>>(),
)
.unwrap();
(tmp, dir)
}
@@ -236,7 +240,9 @@ mod tests {
assert_eq!(ArchiveFormat::parse("tgz"), Some(ArchiveFormat::TarGz));
assert_eq!(ArchiveFormat::parse("tar.zst"), Some(ArchiveFormat::TarZst));
assert_eq!(ArchiveFormat::parse("tzst"), Some(ArchiveFormat::TarZst));
for bad in ["", "ZIP", "gzip", "rar", "7z", "tar.bz2", "tar.xz", "tar.zstx", "tar.gz "] {
for bad in [
"", "ZIP", "gzip", "rar", "7z", "tar.bz2", "tar.xz", "tar.zstx", "tar.gz ",
] {
assert_eq!(ArchiveFormat::parse(bad), None, "{bad:?}");
}
}
@@ -272,10 +278,7 @@ mod tests {
let mut m = BTreeMap::new();
m.insert("top/alpha.txt".to_string(), b"alpha content".to_vec());
m.insert("top/sub/beta.txt".to_string(), b"beta".to_vec());
m.insert(
"top/sub/deep/gamma.bin".to_string(),
(0u8..=255).collect(),
);
m.insert("top/sub/deep/gamma.bin".to_string(), (0u8..=255).collect());
m
}
@@ -299,10 +302,7 @@ mod tests {
assert_eq!(map, expected_map());
// Directory entries are present and the order is deterministic.
let names: Vec<String> = zip
.file_names()
.map(|n| n.unwrap().to_string())
.collect();
let names: Vec<String> = zip.file_names().map(|n| n.unwrap().to_string()).collect();
let has = |n: &str| names.iter().any(|x| x == n);
assert!(has("top/"));
assert!(has("top/sub/"));
Mserver/src/assets.rs
@@ -5,7 +5,8 @@
use std::path::PathBuf;
#[cfg(feature = "embedded")]
mod embedded { use rust_embed::RustEmbed;
mod embedded {
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "dist/"]
@@ -16,7 +17,9 @@ mod embedded { use rust_embed::RustEmbed;
/// Returns (bytes, content-type, cache-control).
pub(crate) fn get_asset(path: &str) -> Option<(Vec<u8>, String, String)> {
let (bytes, from_disk) = read(path)?;
let mime = mime_guess::from_path(path).first_or_octet_stream().to_string();
let mime = mime_guess::from_path(path)
.first_or_octet_stream()
.to_string();
let cache = if from_disk || path == "index.html" {
"no-cache".to_string()
} else {
@@ -35,9 +38,7 @@ fn read(path: &str) -> Option<(Vec<u8>, bool)> {
fn dev_dist_dir() -> PathBuf {
std::env::var_os("FBNG_DIST")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../web/dist")
})
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../web/dist"))
}
#[cfg(not(feature = "embedded"))]
Mserver/src/auth.rs
@@ -17,7 +17,9 @@ pub fn verify_password(password: &str, hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else {
return false;
};
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
/// 32 random bytes, hex-encoded (64 chars).
@@ -42,9 +44,8 @@ fn hex_token(bytes: usize) -> 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}"
);
let mut c =
format!("{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}");
if https {
c.push_str("; Secure");
}
@@ -137,7 +138,10 @@ mod tests {
#[test]
fn parse_session_cookie_variants() {
let mut h = HeaderMap::new();
h.insert(header::COOKIE, "other=1; fbng_session=abc123; x=y".parse().unwrap());
h.insert(
header::COOKIE,
"other=1; fbng_session=abc123; x=y".parse().unwrap(),
);
assert_eq!(parse_session_cookie(&h).as_deref(), Some("abc123"));
let mut h = HeaderMap::new();
@@ -161,7 +165,10 @@ mod tests {
// Cookie name must match exactly.
let mut h = HeaderMap::new();
h.insert(header::COOKIE, "fbng_session2=x; Xfbng_session=y".parse().unwrap());
h.insert(
header::COOKIE,
"fbng_session2=x; Xfbng_session=y".parse().unwrap(),
);
assert_eq!(parse_session_cookie(&h), None);
}
}
Mserver/src/db.rs
@@ -78,9 +78,11 @@ impl Db {
[],
)?;
let version: i64 = conn
.query_row("SELECT value FROM meta WHERE key = 'schema_version'", [], |r| {
r.get::<_, String>(0)
})
.query_row(
"SELECT value FROM meta WHERE key = 'schema_version'",
[],
|r| r.get::<_, String>(0),
)
.optional()?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
@@ -91,9 +93,7 @@ impl Db {
if version < 2 {
// User management (M7): a disabled flag so admins can suspend
// accounts without deleting them.
conn.execute_batch(
"ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1",
)?;
conn.execute_batch("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1")?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
@@ -140,15 +140,7 @@ impl Db {
.query_row(
"SELECT id, name, is_admin != 0, pass_hash, active != 0 FROM users WHERE name = ?1",
[name],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
))
},
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)
.optional()
.ok()
@@ -207,20 +199,18 @@ impl Db {
pub async fn user_roots(&self, user_id: i64) -> Vec<RootRow> {
let c = self.0.lock().await;
let mut out = Vec::new();
let Ok(mut stmt) = c
.prepare("SELECT id, path, mode FROM user_roots WHERE user_id = ?1 ORDER BY id")
let Ok(mut stmt) =
c.prepare("SELECT id, path, mode FROM user_roots WHERE user_id = ?1 ORDER BY id")
else {
return out;
};
if let Ok(rows) =
stmt.query_map([user_id], |r| {
Ok(RootRow {
id: r.get(0)?,
path: r.get(1)?,
mode: r.get(2)?,
})
if let Ok(rows) = stmt.query_map([user_id], |r| {
Ok(RootRow {
id: r.get(0)?,
path: r.get(1)?,
mode: r.get(2)?,
})
{
}) {
out.extend(rows.flatten());
}
out
@@ -231,9 +221,9 @@ impl Db {
pub async fn all_users(&self) -> Vec<User> {
let c = self.0.lock().await;
let mut out = Vec::new();
if let Ok(mut stmt) = c.prepare(
"SELECT id, name, is_admin != 0, active != 0 FROM users ORDER BY id",
) {
if let Ok(mut stmt) =
c.prepare("SELECT id, name, is_admin != 0, active != 0 FROM users ORDER BY id")
{
if let Ok(rows) = stmt.query_map([], |r| {
Ok(User {
id: r.get(0)?,
@@ -396,7 +386,15 @@ impl Db {
c.execute(
"INSERT INTO shares (token, creator_id, target, is_file, mode, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![token, creator_id, target, is_file as i64, mode, now(), expires_at],
params![
token,
creator_id,
target,
is_file as i64,
mode,
now(),
expires_at
],
)?;
let id = c.last_insert_rowid();
Ok(ShareRow {
@@ -413,13 +411,15 @@ impl Db {
pub async fn share_by_token(&self, token: &str) -> Option<ShareRow> {
let c = self.0.lock().await;
c.query_row(&*SHARE_BY_TOKEN, [token], map_share).ok()
let sql = SHARE_BY_TOKEN.as_str();
c.query_row(sql, [token], map_share).ok()
}
pub async fn user_shares(&self, creator_id: i64) -> Vec<ShareRow> {
let c = self.0.lock().await;
let mut out = Vec::new();
if let Ok(mut stmt) = c.prepare(&*USER_SHARES) {
let sql = USER_SHARES.as_str();
if let Ok(mut stmt) = c.prepare(sql) {
if let Ok(rows) = stmt.query_map([creator_id], map_share) {
out.extend(rows.flatten());
}
@@ -464,23 +464,18 @@ impl Db {
self.get_setting("allow_writable_shares").await.as_deref() == Some("1")
}
pub async fn set_allow_writable_shares(
&self,
v: bool,
) -> Result<(), rusqlite::Error> {
pub async fn set_allow_writable_shares(&self, v: bool) -> Result<(), rusqlite::Error> {
self.set_setting("allow_writable_shares", if v { "1" } else { "0" })
.await
}
}
const SHARE_COLS: &str =
"id, token, creator_id, target, is_file, mode, created_at, expires_at";
const SHARE_BY_TOKEN: std::sync::LazyLock<String> =
const SHARE_COLS: &str = "id, token, creator_id, target, is_file, mode, created_at, expires_at";
static SHARE_BY_TOKEN: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| format!("SELECT {SHARE_COLS} FROM shares WHERE token = ?1"));
const USER_SHARES: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| {
format!("SELECT {SHARE_COLS} FROM shares WHERE creator_id = ?1 ORDER BY id DESC")
});
static USER_SHARES: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
format!("SELECT {SHARE_COLS} FROM shares WHERE creator_id = ?1 ORDER BY id DESC")
});
fn map_share(r: &rusqlite::Row) -> rusqlite::Result<ShareRow> {
Ok(ShareRow {
@@ -663,9 +658,12 @@ mod tests {
// Root replacement semantics.
let roots = db.user_roots(bob.id).await;
assert_eq!(roots.len(), 1);
db.set_user_roots(bob.id, &[(".".into(), "ro".into()), ("docs".into(), "rw".into())])
.await
.unwrap();
db.set_user_roots(
bob.id,
&[(".".into(), "ro".into()), ("docs".into(), "rw".into())],
)
.await
.unwrap();
let roots = db.user_roots(bob.id).await;
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|r| r.path == "." && r.mode == "ro"));
@@ -722,7 +720,14 @@ mod tests {
.await
.unwrap();
let s2 = db
.create_share(admin.id, "tok-b", "file.txt", true, "rw", Some("2999-01-01T00:00:00Z"))
.create_share(
admin.id,
"tok-b",
"file.txt",
true,
"rw",
Some("2999-01-01T00:00:00Z"),
)
.await
.unwrap();
assert!(s2.id > s1.id);
Mserver/src/error.rs
@@ -80,10 +80,8 @@ mod tests {
#[test]
fn plain_error_body() {
let (status, body) = status_and_body(ApiError::new(
StatusCode::NOT_FOUND,
"folder not found",
));
let (status, body) =
status_and_body(ApiError::new(StatusCode::NOT_FOUND, "folder not found"));
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap(),
Mserver/src/fs.rs
@@ -44,9 +44,7 @@ impl From<FsError> for ApiError {
/// absolute path, verified to be inside the server root.
pub fn resolve_root(server_root: &Path, root_rel: &str) -> Result<PathBuf, FsError> {
let candidate = server_root.join(root_rel);
let canonical = candidate
.canonicalize()
.map_err(|_| FsError::RootMissing)?;
let canonical = candidate.canonicalize().map_err(|_| FsError::RootMissing)?;
ensure_within(server_root, &canonical)?;
if !canonical.is_dir() {
return Err(FsError::RootMissing);
@@ -64,12 +62,10 @@ pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result
}
}
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 = full.canonicalize().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound,
_ => FsError::Forbidden,
})?;
ensure_within(&root_abs, &full)?;
Ok(full)
}
@@ -79,12 +75,10 @@ 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 = full.canonicalize().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound,
_ => FsError::Forbidden,
})?;
ensure_within(server_root, &full)?;
Ok(full)
}
@@ -189,7 +183,11 @@ pub fn mkdir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), Fs
}
/// Resolve a path that does not need to exist yet, but whose *parent* must.
fn resolve_path_or_new(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
fn resolve_path_or_new(
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() {
@@ -400,7 +398,10 @@ pub fn copy_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
let meta = std::fs::metadata(src).map_err(|e| io_err(e, src))?;
if meta.is_dir() {
std::fs::create_dir(dst).map_err(|e| io_err(e, dst))?;
for e in std::fs::read_dir(src).map_err(|e| io_err(e, src))?.flatten() {
for e in std::fs::read_dir(src)
.map_err(|e| io_err(e, src))?
.flatten()
{
copy_recursive(&e.path(), &dst.join(e.file_name()))?;
}
} else {
@@ -509,13 +510,13 @@ mod tests {
let t = T::new();
let root = t.root.canonicalize().unwrap();
let sib = t.sibling("escape");
let sib_rel = sib
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let sib_rel = sib.file_name().unwrap().to_string_lossy().into_owned();
// Escapes that land on *existing* paths outside the root.
for esc in ["..".to_string(), "docs/../..".to_string(), format!("../{sib_rel}")] {
for esc in [
"..".to_string(),
"docs/../..".to_string(),
format!("../{sib_rel}"),
] {
assert!(
matches!(resolve_root(&root, &esc), Err(FsError::Forbidden)),
"{esc:?} should be forbidden"
@@ -524,7 +525,7 @@ mod tests {
// Escapes to non-existing paths simply don't exist.
for esc in ["../no-such-dir", "a/b/../../..", "nope"] {
assert!(
matches!(resolve_root(&root, &esc), Err(FsError::RootMissing)),
matches!(resolve_root(&root, esc), Err(FsError::RootMissing)),
"{esc:?} should be missing"
);
}
@@ -560,7 +561,10 @@ mod tests {
resolve_path(&root, "docs", "inner/hello.txt").unwrap(),
root.join("docs/inner/hello.txt")
);
assert_eq!(resolve_path(&root, ".", "file.txt").unwrap(), root.join("file.txt"));
assert_eq!(
resolve_path(&root, ".", "file.txt").unwrap(),
root.join("file.txt")
);
}
#[test]
@@ -616,18 +620,17 @@ mod tests {
fn resolve_file_targets_files() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert_eq!(resolve_file(&root, "file.txt").unwrap(), root.join("file.txt"));
assert_eq!(
resolve_file(&root, "file.txt").unwrap(),
root.join("file.txt")
);
assert!(matches!(
resolve_file(&root, "nope.txt"),
Err(FsError::NotFound)
));
// Escape to an existing sibling file.
let sib = t.sibling("escape");
let sib_rel = sib
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let sib_rel = sib.file_name().unwrap().to_string_lossy().into_owned();
std::fs::write(sib.join("s.txt"), "x").unwrap();
assert!(matches!(
resolve_file(&root, &format!("../{sib_rel}/s.txt")),
@@ -644,7 +647,10 @@ mod tests {
resolve_dir(&root, ".", "file.txt"),
Err(FsError::NotADirectory)
));
assert!(matches!(resolve_dir(&root, ".", "nope"), Err(FsError::NotFound)));
assert!(matches!(
resolve_dir(&root, ".", "nope"),
Err(FsError::NotFound)
));
}
// ---------- list_dir ----------
@@ -713,10 +719,7 @@ mod tests {
fn mkdir_rejects_conflict_and_bad_names() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert!(matches!(
mkdir(&root, ".", "docs"),
Err(FsError::Conflict)
));
assert!(matches!(mkdir(&root, ".", "docs"), Err(FsError::Conflict)));
assert!(matches!(
mkdir(&root, ".", "a/b/../../c"),
Err(FsError::Forbidden)
@@ -794,9 +797,9 @@ mod tests {
fn remove_file_and_dir() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert_eq!(remove_item(&root, ".", "file.txt").unwrap(), false);
assert!(!remove_item(&root, ".", "file.txt").unwrap());
assert!(!root.join("file.txt").exists());
assert_eq!(remove_item(&root, ".", "docs").unwrap(), true);
assert!(remove_item(&root, ".", "docs").unwrap());
assert!(!root.join("docs").exists());
assert!(matches!(
remove_item(&root, ".", "file.txt"),
@@ -903,8 +906,8 @@ mod tests {
// File onto file: conflict without overwrite, replaced with.
std::fs::write(root.join("tmp-x.txt"), "x").unwrap();
std::fs::write(root.join("tmp-y.txt"), "y").unwrap();
std::fs::rename(&root.join("tmp-x.txt"), &root.join("tmp-target.txt")).unwrap();
std::fs::rename(&root.join("tmp-y.txt"), &root.join("tmp-target2.txt")).unwrap();
std::fs::rename(root.join("tmp-x.txt"), root.join("tmp-target.txt")).unwrap();
std::fs::rename(root.join("tmp-y.txt"), root.join("tmp-target2.txt")).unwrap();
// Two distinct files with the same name in one folder.
std::fs::create_dir_all(root.join("mv/dst")).unwrap();
std::fs::create_dir_all(root.join("mv/out2")).unwrap();
@@ -969,10 +972,16 @@ mod tests {
));
std::fs::write(root.join("file.txt"), "v2").unwrap();
copy_item(&root, ".", "file.txt", ".", "src", true).unwrap();
assert_eq!(std::fs::read_to_string(root.join("src/file.txt")).unwrap(), "v2");
assert_eq!(
std::fs::read_to_string(root.join("src/file.txt")).unwrap(),
"v2"
);
// Copying onto itself is a no-op success.
copy_item(&root, ".", "src/file.txt", ".", "src", false).unwrap();
assert_eq!(std::fs::read_to_string(root.join("src/file.txt")).unwrap(), "v2");
assert_eq!(
std::fs::read_to_string(root.join("src/file.txt")).unwrap(),
"v2"
);
// Missing destination dir.
assert!(matches!(
copy_item(&root, ".", "file.txt", ".", "nope", false),
Mserver/src/lib.rs
@@ -45,10 +45,7 @@ pub async fn build_app(cli: &Cli) -> anyhow::Result<(axum::Router, SocketAddr)>
let app = api::router(state).layer(TraceLayer::new_for_http());
let ip: IpAddr = cli
.bind
.parse()
.context("invalid --bind address")?;
let ip: IpAddr = cli.bind.parse().context("invalid --bind address")?;
let addr = SocketAddr::new(ip, cli.port);
Ok((app, addr))
}
Mserver/tests/api_admin.rs
@@ -13,17 +13,29 @@ async fn admin_routes_require_admin() {
// No session → 401.
let anon = Client::new(env.app.clone());
assert_eq!(anon.get("/api/admin/users").await.status, StatusCode::UNAUTHORIZED);
assert_eq!(anon.get("/api/admin/settings").await.status, StatusCode::UNAUTHORIZED);
assert_eq!(
anon.get("/api/admin/users").await.status,
StatusCode::UNAUTHORIZED
);
assert_eq!(
anon.get("/api/admin/settings").await.status,
StatusCode::UNAUTHORIZED
);
// Non-admin session → 403.
create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await;
let bob = login(&env, "bob", "bobpass123").await;
assert_eq!(bob.get("/api/admin/users").await.status, StatusCode::FORBIDDEN);
assert_eq!(
bob.put_json("/api/admin/settings", &json!({ "allow_writable_shares": true }))
.await
.status,
bob.get("/api/admin/users").await.status,
StatusCode::FORBIDDEN
);
assert_eq!(
bob.put_json(
"/api/admin/settings",
&json!({ "allow_writable_shares": true })
)
.await
.status,
StatusCode::FORBIDDEN
);
}
@@ -34,7 +46,13 @@ async fn user_lifecycle() {
let admin = env.admin().await;
// Create.
let j = create_user(&admin, "bob", "bobpass123", &[("docs", "rw"), ("src", "ro")]).await;
let j = create_user(
&admin,
"bob",
"bobpass123",
&[("docs", "rw"), ("src", "ro")],
)
.await;
let bob_id = j["id"].as_i64().unwrap();
assert_eq!(j["name"], "bob");
assert_eq!(j["is_admin"], false);
@@ -78,7 +96,11 @@ async fn user_lifecycle() {
&json!({ "name": "dave", "password": "longenough1", "roots": [{ "path": bad, "mode": "rw" }] }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "root '{bad}' should be rejected");
assert_eq!(
r.status,
StatusCode::BAD_REQUEST,
"root '{bad}' should be rejected"
);
}
// Bad mode → 400.
let r = admin
@@ -91,15 +113,28 @@ async fn user_lifecycle() {
// Update: password reset.
let r = admin
.put_json(&format!("/api/admin/users/{bob_id}"), &json!({ "password": "newpass123" }))
.put_json(
&format!("/api/admin/users/{bob_id}"),
&json!({ "password": "newpass123" }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(login(&env, "bob", "newpass123").await.get("/api/auth/me").await.status == StatusCode::OK);
assert!(
login(&env, "bob", "newpass123")
.await
.get("/api/auth/me")
.await
.status
== StatusCode::OK
);
let anon = Client::new(env.app.clone());
assert_eq!(
anon.post_json("/api/auth/login", &json!({ "name": "bob", "password": "bobpass123" }))
.await
.status,
anon.post_json(
"/api/auth/login",
&json!({ "name": "bob", "password": "bobpass123" })
)
.await
.status,
StatusCode::UNAUTHORIZED
);
@@ -118,31 +153,46 @@ async fn user_lifecycle() {
// Update: make admin, then demote.
let r = admin
.put_json(&format!("/api/admin/users/{bob_id}"), &json!({ "is_admin": true }))
.put_json(
&format!("/api/admin/users/{bob_id}"),
&json!({ "is_admin": true }),
)
.await;
assert_eq!(r.json()["is_admin"], true);
// bob can now use the admin API.
let bob = login(&env, "bob", "newpass123").await;
assert_eq!(bob.get("/api/admin/users").await.status, StatusCode::OK);
let r = admin
.put_json(&format!("/api/admin/users/{bob_id}"), &json!({ "is_admin": false }))
.put_json(
&format!("/api/admin/users/{bob_id}"),
&json!({ "is_admin": false }),
)
.await;
assert_eq!(r.json()["is_admin"], false);
// Update: disable → re-enable.
let r = admin
.put_json(&format!("/api/admin/users/{bob_id}"), &json!({ "active": false }))
.put_json(
&format!("/api/admin/users/{bob_id}"),
&json!({ "active": false }),
)
.await;
assert_eq!(r.json()["active"], false);
let anon = Client::new(env.app.clone());
assert_eq!(
anon.post_json("/api/auth/login", &json!({ "name": "bob", "password": "newpass123" }))
.await
.status,
anon.post_json(
"/api/auth/login",
&json!({ "name": "bob", "password": "newpass123" })
)
.await
.status,
StatusCode::UNAUTHORIZED
);
let r = admin
.put_json(&format!("/api/admin/users/{bob_id}"), &json!({ "active": true }))
.put_json(
&format!("/api/admin/users/{bob_id}"),
&json!({ "active": true }),
)
.await;
assert_eq!(r.json()["active"], true);
@@ -159,7 +209,10 @@ async fn user_lifecycle() {
.collect();
assert_eq!(names, vec!["admin"]);
assert_eq!(
admin.delete(&format!("/api/admin/users/{bob_id}")).await.status,
admin
.delete(&format!("/api/admin/users/{bob_id}"))
.await
.status,
StatusCode::NOT_FOUND
);
}
@@ -173,12 +226,18 @@ async fn lockout_guards() {
// Cannot demote yourself.
let r = admin
.put_json(&format!("/api/admin/users/{my_id}"), &json!({ "is_admin": false }))
.put_json(
&format!("/api/admin/users/{my_id}"),
&json!({ "is_admin": false }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Cannot disable yourself.
let r = admin
.put_json(&format!("/api/admin/users/{my_id}"), &json!({ "active": false }))
.put_json(
&format!("/api/admin/users/{my_id}"),
&json!({ "active": false }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Cannot delete yourself.
@@ -191,12 +250,18 @@ async fn lockout_guards() {
create_user(&admin, "eve", "evepass123", &[("docs", "rw")]).await;
let eve_id = user_id(&admin, "eve").await;
let r = admin
.put_json(&format!("/api/admin/users/{eve_id}"), &json!({ "is_admin": true }))
.put_json(
&format!("/api/admin/users/{eve_id}"),
&json!({ "is_admin": true }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
// Now demoting eve is allowed (two active admins exist).
let r = admin
.put_json(&format!("/api/admin/users/{eve_id}"), &json!({ "is_admin": false }))
.put_json(
&format!("/api/admin/users/{eve_id}"),
&json!({ "is_admin": false }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["is_admin"], false);
@@ -209,7 +274,10 @@ async fn lockout_guards() {
// can't disable self. So: two admins, disable one (allowed), then the
// remaining one is the last → deleting the *disabled* one is allowed.
let r = admin
.put_json(&format!("/api/admin/users/{eve_id}"), &json!({ "is_admin": true, "active": false }))
.put_json(
&format!("/api/admin/users/{eve_id}"),
&json!({ "is_admin": true, "active": false }),
)
.await;
// eve was an admin but is now disabled → not counted. Deleting her:
// target.active == false → the guard (which requires active) does not
@@ -231,7 +299,10 @@ async fn settings_round_trip_visible_in_me() {
assert_eq!(r.json()["allow_writable_shares"], false);
let r = admin
.put_json("/api/admin/settings", &json!({ "allow_writable_shares": true }))
.put_json(
"/api/admin/settings",
&json!({ "allow_writable_shares": true }),
)
.await;
assert_eq!(r.json()["allow_writable_shares"], true);
@@ -242,7 +313,10 @@ async fn settings_round_trip_visible_in_me() {
);
let r = admin
.put_json("/api/admin/settings", &json!({ "allow_writable_shares": false }))
.put_json(
"/api/admin/settings",
&json!({ "allow_writable_shares": false }),
)
.await;
assert_eq!(r.json()["allow_writable_shares"], false);
}
@@ -251,6 +325,8 @@ async fn settings_round_trip_visible_in_me() {
async fn update_unknown_user_is_404() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.put_json("/api/admin/users/9999", &json!({ "active": true })).await;
let r = admin
.put_json("/api/admin/users/9999", &json!({ "active": true }))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
Mserver/tests/api_auth.rs
@@ -24,7 +24,10 @@ async fn setup_validates_input() {
let c = Client::new(env.app.clone());
// Blank name.
let r = c
.post_json("/api/auth/setup", &json!({ "name": " ", "password": "longenough1" }))
.post_json(
"/api/auth/setup",
&json!({ "name": " ", "password": "longenough1" }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Name too long (65 chars).
@@ -37,7 +40,10 @@ async fn setup_validates_input() {
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Password too short.
let r = c
.post_json("/api/auth/setup", &json!({ "name": "admin", "password": "short" }))
.post_json(
"/api/auth/setup",
&json!({ "name": "admin", "password": "short" }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Nothing was created.
@@ -64,7 +70,10 @@ async fn setup_creates_admin_and_sets_cookie() {
// Setup is only available on first boot.
let anon = Client::new(env.app.clone());
let r = anon
.post_json("/api/auth/setup", &json!({ "name": "x", "password": "longenough1" }))
.post_json(
"/api/auth/setup",
&json!({ "name": "x", "password": "longenough1" }),
)
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
}
@@ -76,17 +85,26 @@ async fn login_flows() {
let c = Client::new(env.app.clone());
let r = c
.post_json("/api/auth/login", &json!({ "name": "admin", "password": "wrongpass1" }))
.post_json(
"/api/auth/login",
&json!({ "name": "admin", "password": "wrongpass1" }),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
let r = c
.post_json("/api/auth/login", &json!({ "name": "ghost", "password": "whatever1" }))
.post_json(
"/api/auth/login",
&json!({ "name": "ghost", "password": "whatever1" }),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
let r = c
.post_json("/api/auth/login", &json!({ "name": "admin", "password": "admin1234" }))
.post_json(
"/api/auth/login",
&json!({ "name": "admin", "password": "admin1234" }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(session_cookie(&r).is_some());
@@ -138,15 +156,15 @@ async fn disabled_user_loses_session_and_cannot_login() {
create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await;
let bob = login(&env, "bob", "bobpass123").await;
assert_eq!(
bob.get("/api/auth/me").await.status,
StatusCode::OK
);
assert_eq!(bob.get("/api/auth/me").await.status, StatusCode::OK);
// Admin disables bob.
let id = user_id(&admin, "bob").await;
let r = admin
.put_json(&format!("/api/admin/users/{id}"), &json!({ "active": false }))
.put_json(
&format!("/api/admin/users/{id}"),
&json!({ "active": false }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
Mserver/tests/api_files.rs
@@ -28,10 +28,20 @@ async fn list_root_sorted_folders_first() {
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
let entries = j["entries"].as_array().unwrap();
let names: Vec<&str> = entries.iter().map(|e| e["name"].as_str().unwrap()).collect();
let names: Vec<&str> = entries
.iter()
.map(|e| e["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
vec!["docs", "src", "blob.bin", "config.json", "editme.txt", "notes.md"]
vec![
"docs",
"src",
"blob.bin",
"config.json",
"editme.txt",
"notes.md"
]
);
// Entry fields.
let docs = &entries[0];
@@ -96,7 +106,11 @@ async fn path_traversal_is_blocked() {
);
// Literal `..` segments: must never succeed.
let r = admin.get("/api/files/1/../../etc").await;
assert_ne!(r.status, StatusCode::OK, "literal traversal must not be served");
assert_ne!(
r.status,
StatusCode::OK,
"literal traversal must not be served"
);
// Traversal inside a deeper path.
let r = admin.get("/api/files/1/docs/..%2f..%2fsrc").await;
assert!(
@@ -110,13 +124,20 @@ async fn path_traversal_is_blocked() {
async fn download_single_file() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.get(&format!("{}?action=download", root_path("editme.txt"))).await;
let r = admin
.get(&format!("{}?action=download", root_path("editme.txt")))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.header("content-disposition").as_deref(), Some("attachment; filename=\"editme.txt\""));
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"editme.txt\"")
);
assert_eq!(r.header("content-type").as_deref(), Some("text/plain"));
assert_eq!(r.body, b"v1");
// Binary content survives.
let r = admin.get(&format!("{}?action=download", root_path("blob.bin"))).await;
let r = admin
.get(&format!("{}?action=download", root_path("blob.bin")))
.await;
assert_eq!(r.body, (0..64u8).collect::<Vec<_>>());
}
@@ -128,10 +149,7 @@ async fn download_folder_as_all_archive_formats() {
let r = admin.get(&format!("{path}&format=zip")).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(
r.header("content-type").as_deref(),
Some("application/zip")
);
assert_eq!(r.header("content-type").as_deref(), Some("application/zip"));
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.zip\"")
@@ -141,14 +159,23 @@ async fn download_folder_as_all_archive_formats() {
assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
let r = admin.get(&format!("{path}&format=tar")).await;
assert_eq!(r.header("content-type").as_deref(), Some("application/x-tar"));
assert_eq!(r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.tar\""));
assert_eq!(
r.header("content-type").as_deref(),
Some("application/x-tar")
);
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.tar\"")
);
let map = tar_map(&r.body, Compress::None);
assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
let r = admin.get(&format!("{path}&format=tar.gz")).await;
assert_eq!(r.header("content-type").as_deref(), Some("application/gzip"));
assert_eq!(
r.header("content-type").as_deref(),
Some("application/gzip")
);
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.tar.gz\"")
@@ -157,7 +184,10 @@ async fn download_folder_as_all_archive_formats() {
assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
let r = admin.get(&format!("{path}&format=tar.zst")).await;
assert_eq!(r.header("content-type").as_deref(), Some("application/zstd"));
assert_eq!(
r.header("content-type").as_deref(),
Some("application/zstd")
);
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.tar.zst\"")
@@ -180,7 +210,10 @@ async fn download_folder_requires_valid_format() {
);
// Downloading a file with a format is fine (format ignored).
let r = admin
.get(&format!("{}?action=download&format=zip", root_path("editme.txt")))
.get(&format!(
"{}?action=download&format=zip",
root_path("editme.txt")
))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.body, b"v1");
@@ -190,7 +223,9 @@ async fn download_folder_requires_valid_format() {
async fn preview_serves_inline_and_rejects_dirs() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.get(&format!("{}?action=preview", root_path("config.json"))).await;
let r = admin
.get(&format!("{}?action=preview", root_path("config.json")))
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(r
.header("content-disposition")
@@ -198,7 +233,10 @@ async fn preview_serves_inline_and_rejects_dirs() {
.starts_with("inline;"));
assert_eq!(r.body, b"{\"k\": 1}");
assert_eq!(
admin.get(&format!("{}?action=preview", root_path("docs"))).await.status,
admin
.get(&format!("{}?action=preview", root_path("docs")))
.await
.status,
StatusCode::BAD_REQUEST
);
}
@@ -207,14 +245,22 @@ async fn preview_serves_inline_and_rejects_dirs() {
async fn content_action_serves_raw_bytes_with_mtime() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.get(&format!("{}?action=content", root_path("notes.md"))).await;
let r = admin
.get(&format!("{}?action=content", root_path("notes.md")))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.header("content-type").as_deref(), Some("text/plain; charset=utf-8"));
assert_eq!(
r.header("content-type").as_deref(),
Some("text/plain; charset=utf-8")
);
let mtime = r.header("x-file-mtime").unwrap();
assert!(mtime.parse::<i64>().is_ok());
assert_eq!(r.body, b"# notes");
assert_eq!(
admin.get(&format!("{}?action=content", root_path("docs"))).await.status,
admin
.get(&format!("{}?action=content", root_path("docs")))
.await
.status,
StatusCode::BAD_REQUEST
);
}
@@ -225,10 +271,14 @@ async fn content_is_capped_at_two_mibibytes() {
let admin = env.admin().await;
let big = vec![b'x'; 2 * 1024 * 1024 + 1];
std::fs::write(env.file("big.bin"), &big).unwrap();
let r = admin.get(&format!("{}?action=content", root_path("big.bin"))).await;
let r = admin
.get(&format!("{}?action=content", root_path("big.bin")))
.await;
assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
// The file itself still downloads fine.
let r = admin.get(&format!("{}?action=download", root_path("big.bin"))).await;
let r = admin
.get(&format!("{}?action=download", root_path("big.bin")))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.body.len(), big.len());
}
@@ -277,7 +327,11 @@ async fn editor_save_round_trip_and_conflict() {
assert_eq!(r.status, StatusCode::BAD_REQUEST);
let r = admin
.put_content(&format!("{}?action=content", root_path("ghost.txt")), b"x", None)
.put_content(
&format!("{}?action=content", root_path("ghost.txt")),
b"x",
None,
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
let r = admin
@@ -298,11 +352,25 @@ async fn mkdir_and_rename() {
let admin = env.admin().await;
// mkdir (no content-type → mkdir dispatch).
let r = admin.raw(axum::http::Method::POST, &root_path("newdir"), &[], Vec::new()).await;
let r = admin
.raw(
axum::http::Method::POST,
&root_path("newdir"),
&[],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(env.file("newdir").is_dir());
// Duplicate → 409.
let r = admin.raw(axum::http::Method::POST, &root_path("newdir"), &[], Vec::new()).await;
let r = admin
.raw(
axum::http::Method::POST,
&root_path("newdir"),
&[],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
// Empty name → 400 (bare root POST with JSON op is rejected too).
let r = admin
@@ -346,7 +414,10 @@ async fn mkdir_and_rename() {
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Missing source.
let r = admin
.post_json(&root_path("ghost"), &json!({ "op": "rename", "new_name": "x" }))
.post_json(
&root_path("ghost"),
&json!({ "op": "rename", "new_name": "x" }),
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// Unknown op.
@@ -370,7 +441,10 @@ async fn move_and_copy_across_dirs() {
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(!env.file("notes.md").exists());
assert_eq!(std::fs::read(env.file("docs/notes.md")).unwrap(), b"# notes");
assert_eq!(
std::fs::read(env.file("docs/notes.md")).unwrap(),
b"# notes"
);
// Copy docs/inner back out — as a folder.
let r = admin
@@ -380,7 +454,10 @@ async fn move_and_copy_across_dirs() {
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("src/inner/hello.txt")).unwrap(), b"hello world");
assert_eq!(
std::fs::read(env.file("src/inner/hello.txt")).unwrap(),
b"hello world"
);
assert!(env.file("docs/inner/hello.txt").exists());
// Conflict without overwrite, ok with: copy into a folder that already
@@ -480,11 +557,18 @@ async fn upload_creates_files_and_folders() {
// Single file into the root, nested part name creates the folder.
let r = admin
.post_multipart(&root_path(""), &[("docs/uploaded.txt", b"up1"), ("new/nested.txt", b"up2")], "")
.post_multipart(
&root_path(""),
&[("docs/uploaded.txt", b"up1"), ("new/nested.txt", b"up2")],
"",
)
.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");
assert_eq!(
std::fs::read(env.file("docs/uploaded.txt")).unwrap(),
b"up1"
);
assert_eq!(std::fs::read(env.file("new/nested.txt")).unwrap(), b"up2");
// Conflict: existing file, no overwrite → 409 with the skipped list.
@@ -493,7 +577,10 @@ async fn upload_creates_files_and_folders() {
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"up1");
assert_eq!(
std::fs::read(env.file("docs/uploaded.txt")).unwrap(),
b"up1"
);
// Mixed: one conflict + one new file → 409, the new one is uploaded.
let r = admin
@@ -510,7 +597,11 @@ async fn upload_creates_files_and_folders() {
// overwrite=true replaces.
let r = admin
.post_multipart(&root_path(""), &[("docs/uploaded.txt", b"v3")], "overwrite=true")
.post_multipart(
&root_path(""),
&[("docs/uploaded.txt", b"v3")],
"overwrite=true",
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"v3");
@@ -535,7 +626,12 @@ async fn upload_creates_files_and_folders() {
// No parts at all → 400.
let (ct, body) = multipart_body(&[], "b");
let r = admin
.raw(axum::http::Method::POST, &root_path(""), &[("content-type", &ct)], body)
.raw(
axum::http::Method::POST,
&root_path(""),
&[("content-type", &ct)],
body,
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
}
@@ -555,20 +651,24 @@ async fn read_only_root_blocks_writes_but_allows_reads() {
assert_eq!(r.status, StatusCode::OK);
assert!(!r.json()["entries"].as_array().unwrap().is_empty());
let r = carol
.get(&format!(
"/api/files/{carol_root_id}/a.txt?action=download"
))
.get(&format!("/api/files/{carol_root_id}/a.txt?action=download"))
.await;
assert_eq!(r.body, b"file a");
// Writes are blocked.
let base = format!("/api/files/{carol_root_id}/x");
assert_eq!(
carol.raw(axum::http::Method::POST, &base, &[], Vec::new()).await.status,
carol
.raw(axum::http::Method::POST, &base, &[], Vec::new())
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(
carol.delete(&format!("/api/files/{carol_root_id}/a.txt")).await.status,
carol
.delete(&format!("/api/files/{carol_root_id}/a.txt"))
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(
@@ -602,10 +702,14 @@ async fn user_cannot_touch_foreign_root() {
assert_eq!(dave.get("/api/files/1").await.status, StatusCode::FORBIDDEN);
// Writing into a root he doesn't have → 403.
assert_eq!(
dave
.raw(axum::http::Method::POST, "/api/files/1/evil", &[], Vec::new())
.await
.status,
dave.raw(
axum::http::Method::POST,
"/api/files/1/evil",
&[],
Vec::new()
)
.await
.status,
StatusCode::FORBIDDEN
);
}
Mserver/tests/common/mod.rs
@@ -86,12 +86,7 @@ impl Env {
&serde_json::json!({ "name": "admin", "password": "admin1234" }),
)
.await;
assert_eq!(
r.status,
StatusCode::OK,
"setup failed: {}",
r.text()
);
assert_eq!(r.status, StatusCode::OK, "setup failed: {}", r.text());
let token = session_cookie(&r).expect("setup must set a session cookie");
let mut c = Client::new(self.app.clone());
c.set_cookie(&token);
@@ -207,30 +202,27 @@ impl Client {
}
/// `PUT ...?action=content` (editor save).
pub async fn put_content(&self, path: &str, content: &[u8], expected_mtime: Option<i64>) -> Resp {
pub async fn put_content(
&self,
path: &str,
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 owned: Vec<(String, String)> = extra
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
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
self.raw(Method::PUT, path, &hdrs, content.to_vec()).await
}
/// POST a multipart upload with one file per part name.
pub async fn post_multipart(
&self,
path: &str,
parts: &[(&str, &[u8])],
query: &str,
) -> Resp {
pub async fn post_multipart(&self, path: &str, parts: &[(&str, &[u8])], query: &str) -> Resp {
let boundary = "testboundary123";
let (ct, body) = multipart_body(parts, boundary);
let full = if query.is_empty() {
@@ -339,8 +331,7 @@ pub fn multipart_body(parts: &[(&str, &[u8])], boundary: &str) -> (String, Vec<u
/// Read a zip into a name → content map (files only).
pub fn zip_map(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut zip =
zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("valid zip archive");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("valid zip archive");
let mut map = BTreeMap::new();
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
@@ -360,9 +351,9 @@ pub fn tar_map(raw: &[u8], compress: Compress) -> BTreeMap<String, Vec<u8>> {
let decompressed: Box<dyn std::io::Read> = match compress {
Compress::None => Box::new(std::io::Cursor::new(raw)),
Compress::Gz => Box::new(flate2::read::GzDecoder::new(std::io::Cursor::new(raw))),
Compress::Zst => Box::new(
zstd::stream::read::Decoder::new(std::io::Cursor::new(raw)).unwrap(),
),
Compress::Zst => {
Box::new(zstd::stream::read::Decoder::new(std::io::Cursor::new(raw)).unwrap())
}
};
let mut map = BTreeMap::new();
for entry in tar::Archive::new(decompressed).entries().unwrap() {
Mweb/src/api.rs
@@ -25,7 +25,9 @@ pub enum ApiError {
impl ApiError {
pub fn skipped(&self) -> Option<&[String]> {
match self {
ApiError::Http { skipped: Some(s), .. } => Some(s),
ApiError::Http {
skipped: Some(s), ..
} => Some(s),
_ => None,
}
}
@@ -200,7 +202,10 @@ pub fn list_files(
}
/// Create a folder. `path` is relative to the root (may contain subfolders).
pub fn mkdir(root_id: i64, path: &str) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
pub fn mkdir(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request_no_body("POST", files_url(root_id, path))
}
@@ -327,17 +332,18 @@ pub async fn fetch_content(root_id: i64, path: &str) -> Result<String, ApiError>
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body.error.unwrap_or_else(|| "could not read file".to_string()),
message: body
.error
.unwrap_or_else(|| "could not read file".to_string()),
skipped: None,
});
}
let tp = resp
.text()
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let tp = resp.text().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js = JsFuture::from(tp)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
js.as_string().ok_or_else(|| ApiError::Net("content is not a string".to_string()))
js.as_string()
.ok_or_else(|| ApiError::Net("content is not a string".to_string()))
}
/// Fetch a file's raw text content plus its mtime (unix seconds), for the
@@ -365,7 +371,9 @@ pub async fn fetch_content_meta(
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body.error.unwrap_or_else(|| "could not read file".to_string()),
message: body
.error
.unwrap_or_else(|| "could not read file".to_string()),
skipped: None,
});
}
@@ -375,9 +383,7 @@ pub async fn fetch_content_meta(
.ok()
.flatten()
.and_then(|s| s.parse::<i64>().ok());
let tp = resp
.text()
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let tp = resp.text().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js = JsFuture::from(tp)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
@@ -437,13 +443,13 @@ pub async fn save_content(
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body.error.unwrap_or_else(|| "could not save file".to_string()),
message: body
.error
.unwrap_or_else(|| "could not save file".to_string()),
skipped: None,
});
}
let js = resp
.json()
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js: JsValue = JsFuture::from(js)
.await
.map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
@@ -475,7 +481,7 @@ pub fn trigger_download(url: &str, filename: &str) {
let _ = body.append_child(&a);
}
a.click();
let _ = a.remove();
a.remove();
}
/// Upload files into a directory. Each part is `(relative_path, file)`;
@@ -550,9 +556,7 @@ pub async fn upload(
skipped: body.skipped,
});
}
let js = resp
.json()
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js: JsValue = JsFuture::from(js)
.await
.map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
@@ -730,7 +734,9 @@ pub fn update_admin_settings(
request(
"PUT",
"/api/admin/settings".to_string(),
Some(AdminSettings { allow_writable_shares }),
Some(AdminSettings {
allow_writable_shares,
}),
)
}
@@ -797,7 +803,7 @@ pub fn pick_files(
}
}
}
let _ = input.remove();
input.remove();
if !files.is_empty() {
on_files(files);
}
@@ -827,11 +833,9 @@ async fn request<T: DeserializeOwned>(
opts.set_method(method);
opts.set_mode(web_sys::RequestMode::SameOrigin);
if let Some(body) = body {
let json = serde_json_to_string(&body)
.map_err(|e| ApiError::Net(e.to_string()))?;
let json = serde_json_to_string(&body).map_err(|e| ApiError::Net(e.to_string()))?;
opts.set_body_opt_str(Some(&json));
let headers = web_sys::Headers::new()
.expect("Headers constructor failed");
let headers = web_sys::Headers::new().expect("Headers constructor failed");
let _ = headers.set("Content-Type", "application/json");
opts.set_headers_headers(&headers);
}
@@ -840,10 +844,7 @@ async fn request<T: DeserializeOwned>(
}
/// Request without a body (mkdir / delete).
async fn request_no_body<T: DeserializeOwned>(
method: &str,
url: String,
) -> Result<T, ApiError> {
async fn request_no_body<T: DeserializeOwned>(method: &str, url: String) -> Result<T, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let opts = web_sys::RequestInit::new();
@@ -878,9 +879,7 @@ async fn do_fetch<T: DeserializeOwned>(
});
}
let json_promise = resp
.json()
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let json_promise = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js: JsValue = JsFuture::from(json_promise)
.await
.map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
@@ -932,10 +931,8 @@ fn serde_json_to_string(v: &impl Serialize) -> Result<String, serde_wasm_bindgen
// Reuse the wasm-bindgen JSON serializer; the body must be a plain string
// so we convert via JSON text.
let value = serde_wasm_bindgen::to_value(v)?;
let s = js_sys::JSON::stringify(&value).map_err(|e| {
serde_wasm_bindgen::Error::new(format!("JSON.stringify failed: {e:?}"))
})?;
s.as_string().ok_or_else(|| {
serde_wasm_bindgen::Error::new("stringify returned a non-string")
})
let s = js_sys::JSON::stringify(&value)
.map_err(|e| serde_wasm_bindgen::Error::new(format!("JSON.stringify failed: {e:?}")))?;
s.as_string()
.ok_or_else(|| serde_wasm_bindgen::Error::new("stringify returned a non-string"))
}
Mweb/src/app.rs
@@ -20,9 +20,7 @@ pub fn App() -> impl IntoView {
// The URL hash is the source of truth for the location.
{
let set_loc = set_loc;
set_loc.set(parse_location());
let set_loc = set_loc;
let _h = window_event_listener(leptos::ev::hashchange, move |_| {
set_loc.set(parse_location());
});
Mweb/src/cm.rs
@@ -25,8 +25,7 @@ fn err_str(e: JsValue) -> String {
fn global() -> Result<JsValue, String> {
let w = web_sys::window().ok_or("no window".to_string())?;
let wjs: JsValue = w.into();
let v = js_sys::Reflect::get(&wjs, &JsValue::from_str("__fbng_cm"))
.map_err(|e| err_str(e))?;
let v = js_sys::Reflect::get(&wjs, &JsValue::from_str("__fbng_cm")).map_err(err_str)?;
if v.is_null() || v.is_undefined() {
return Err("cm6 bundle not loaded".to_string());
}
@@ -35,13 +34,13 @@ fn global() -> Result<JsValue, String> {
/// Call `method` on `g` with the given args.
fn call(g: &JsValue, method: &str, args: &[&JsValue]) -> Result<JsValue, String> {
let m = js_sys::Reflect::get(g, &JsValue::from_str(method)).map_err(|e| err_str(e))?;
let m = js_sys::Reflect::get(g, &JsValue::from_str(method)).map_err(err_str)?;
let f: js_sys::Function = m.unchecked_into();
let arr = js_sys::Array::new();
for a in args {
arr.push(a);
}
js_sys::Reflect::apply(&f, g, &arr).map_err(|e| err_str(e))
js_sys::Reflect::apply(&f, g, &arr).map_err(err_str)
}
/// Can this file name be shown in the text editor? (Delegated to the bundle so
@@ -68,24 +67,32 @@ pub fn create(
) -> Result<Cm, String> {
let g = global()?;
let opts = js_sys::Object::new();
js_sys::Reflect::set(&opts, &JsValue::from_str("value"), &JsValue::from_str(value))
.map_err(|e| err_str(e))?;
js_sys::Reflect::set(&opts, &JsValue::from_str("filename"), &JsValue::from_str(filename))
.map_err(|e| err_str(e))?;
js_sys::Reflect::set(&opts, &JsValue::from_str("editable"), &JsValue::from_bool(editable))
.map_err(|e| err_str(e))?;
js_sys::Reflect::set(
&opts,
&JsValue::from_str("value"),
&JsValue::from_str(value),
)
.map_err(err_str)?;
js_sys::Reflect::set(
&opts,
&JsValue::from_str("filename"),
&JsValue::from_str(filename),
)
.map_err(err_str)?;
js_sys::Reflect::set(
&opts,
&JsValue::from_str("editable"),
&JsValue::from_bool(editable),
)
.map_err(err_str)?;
if let Some(f) = on_update {
js_sys::Reflect::set(&opts, &JsValue::from_str("onUpdate"), f)
.map_err(|e| err_str(e))?;
js_sys::Reflect::set(&opts, &JsValue::from_str("onUpdate"), f).map_err(err_str)?;
}
let view = call(
&g,
"create",
&[
&JsValue::from(container),
&JsValue::from(&opts),
],
&[&JsValue::from(container), &JsValue::from(&opts)],
)?;
Ok(Cm { view })
}
Mweb/src/components/icon.rs
@@ -34,18 +34,15 @@ pub fn icon_for(name: &str, is_dir: bool) -> IconName {
}
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "avif" => {
IconName::Image
}
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "avif" => IconName::Image,
"mp4" | "webm" | "mkv" | "mov" | "avi" => IconName::Video,
"mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" => IconName::Audio,
"pdf" => IconName::Pdf,
"zip" | "tar" | "gz" | "tgz" | "zst" | "bz2" | "xz" | "7z" | "rar" => {
IconName::Archive
"zip" | "tar" | "gz" | "tgz" | "zst" | "bz2" | "xz" | "7z" | "rar" => IconName::Archive,
"rs" | "js" | "ts" | "tsx" | "jsx" | "py" | "rb" | "go" | "c" | "cpp" | "h" | "hpp"
| "json" | "toml" | "yaml" | "yml" | "sh" | "html" | "css" | "wasm" | "sql" => {
IconName::Code
}
"rs" | "js" | "ts" | "tsx" | "jsx" | "py" | "rb" | "go" | "c" | "cpp"
| "h" | "hpp" | "json" | "toml" | "yaml" | "yml" | "sh" | "html" | "css"
| "wasm" | "sql" => IconName::Code,
"txt" | "md" | "log" | "csv" | "ini" | "conf" | "xml" => IconName::Text,
_ => IconName::File,
}
Mweb/src/editor.rs
@@ -28,6 +28,7 @@ pub struct EditTarget {
/// 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,
force: bool,
@@ -41,7 +42,7 @@ async fn do_save(
refresh: Callback<()>,
) {
let text = match cm_view.lock() {
Ok(g) => g.as_ref().and_then(|v| cm::get_value(v)),
Ok(g) => g.as_ref().and_then(cm::get_value),
Err(_) => None,
};
let Some(text) = text else {
@@ -158,7 +159,7 @@ pub fn EditorModal(
let t2 = t.clone();
let cmv2 = cmv.clone();
let lt2 = lt.clone();
let refresh2 = refresh.clone();
let refresh2 = refresh;
spawn_local(async move {
do_save(t2, false, &cmv2, <2, sd, ss, st, sc, toast, refresh2).await;
});
@@ -176,7 +177,7 @@ pub fn EditorModal(
let t2 = t.clone();
let cmv2 = cmv.clone();
let lt2 = lt.clone();
let refresh2 = refresh.clone();
let refresh2 = refresh;
spawn_local(async move {
do_save(t2, true, &cmv2, <2, sd, ss, st, sc, toast, refresh2).await;
});
@@ -219,7 +220,6 @@ pub fn EditorModal(
// Close, prompting first if there are unsaved changes.
let request_close: Callback<()> = {
let close = close.clone();
let d = dirty;
let scd = set_confirm_discard;
Callback::new(move |_| {
@@ -233,7 +233,7 @@ pub fn EditorModal(
// Ctrl/Cmd+S saves (scoped to the editor's lifetime).
{
let cb = save_cb.clone();
let cb = save_cb;
let _h = window_event_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();
@@ -249,7 +249,7 @@ pub fn EditorModal(
let cd = confirm_discard;
let scd = set_confirm_discard;
let cf = conflict;
let rc = request_close.clone();
let rc = request_close;
let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape" {
return;
@@ -295,7 +295,9 @@ pub fn EditorModal(
Some(e) => view! { <div class="cm-error">{e}</div> }
.into_view()
.into_any(),
None => view! {}.into_any(),
None => {
view! {}.into_any()
},
}
}}
</div>
Mweb/src/main.rs
@@ -1,3 +1,7 @@
// Leptos' empty `view! {}` (the "render nothing" idiom) expands to a unit
// expression, which trips these two lints on the macro-generated code.
#![allow(clippy::unit_arg, clippy::unused_unit)]
mod api;
mod app;
mod cm;
Mweb/src/preview.rs
@@ -186,7 +186,9 @@ fn TextPreview(target: PreviewTarget) -> impl IntoView {
Some(e) => view! { <div class="cm-error muted">{e}</div> }
.into_view()
.into_any(),
None => view! {}.into_any(),
None => {
view! {}.into_any()
},
}
}}
</div>
Mweb/src/util.rs
@@ -54,6 +54,13 @@ impl ViewMode {
}
pub fn save(self) {
storage_set(Self::KEY, if self == ViewMode::List { "list" } else { "grid" });
storage_set(
Self::KEY,
if self == ViewMode::List {
"list"
} else {
"grid"
},
);
}
}
Mweb/src/views/admin.rs
@@ -236,21 +236,27 @@ fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
.into_view()
.into_any()
} else {
view! {}.into_view().into_any()
{
view! {}.into_view()
}.into_any()
}}
{if u.is_admin {
view! { <span class="badge">"admin"</span> }
.into_view()
.into_any()
} else {
view! {}.into_view().into_any()
{
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()
{
view! {}.into_view()
}.into_any()
}}
</div>
<div class="user-row-meta muted">{roots_desc.clone()}</div>
@@ -301,7 +307,9 @@ fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
.into_view()
.into_any()
}
None => view! {}.into_any(),
None => {
view! {}.into_any()
},
}}
</div>
}
@@ -326,10 +334,21 @@ fn UserForm(
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let is_new = existing.is_none();
let is_self = existing.as_ref().map(|u| self_id == Some(u.id)).unwrap_or(false);
let original_name = existing.as_ref().map(|u| u.name.clone()).unwrap_or_default();
let is_self = existing
.as_ref()
.map(|u| self_id == Some(u.id))
.unwrap_or(false);
let original_name = existing
.as_ref()
.map(|u| u.name.clone())
.unwrap_or_default();
let (name, set_name) = signal(existing.as_ref().map(|u| u.name.clone()).unwrap_or_default());
let (name, set_name) = signal(
existing
.as_ref()
.map(|u| u.name.clone())
.unwrap_or_default(),
);
let (password, set_password) = signal(String::new());
let (is_admin, set_is_admin) = signal(existing.as_ref().map(|u| u.is_admin).unwrap_or(false));
let (active, set_active) = signal(existing.as_ref().map(|u| u.active).unwrap_or(true));
@@ -419,12 +438,23 @@ fn UserForm(
api::create_admin_user(&n, &pw, adm, &pairs).await
} else {
let id = existing_id.unwrap_or(0);
let pw_opt = if pw.is_empty() { None } else { Some(pw.clone()) };
let pw_opt = if pw.is_empty() {
None
} else {
Some(pw.clone())
};
api::update_admin_user(id, pw_opt, Some(adm), Some(act), Some(pairs)).await
};
match result {
Ok(_) => {
show(toast2, if is_new { "User created".to_string() } else { "User updated".to_string() });
show(
toast2,
if is_new {
"User created".to_string()
} else {
"User updated".to_string()
},
);
on_saved2.run(());
close2.run(());
}
@@ -602,7 +632,9 @@ fn UserForm(
}
.into_view()
.into_any(),
None => view! {}.into_any(),
None => {
view! {}.into_any()
},
}}
<div class="modal-actions">
<button class="btn" on:click=move |_| close.run(())>"Cancel"</button>
Mweb/src/views/browser.rs
@@ -9,9 +9,9 @@ use crate::cm;
use crate::components::icon::{icon_for, Icon, IconName};
use crate::components::toast::{show, ToastMsg};
use crate::editor::{EditTarget, EditorModal};
use crate::preview::{preview_kind, PreviewKind, PreviewModal, PreviewTarget};
use crate::router::{navigate, Location};
use crate::util::{format_date, format_size, ViewMode};
use crate::preview::{preview_kind, PreviewKind, PreviewModal, PreviewTarget};
use crate::views::dialogs::{Dialog, DialogView, Op};
#[derive(Clone, Debug, PartialEq)]
@@ -40,10 +40,7 @@ struct CtxMenu {
}
#[component]
pub fn Browser(
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
) -> impl IntoView {
pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (view_mode, set_view_mode) = signal(ViewMode::load());
let (list_state, set_list_state) = signal(ListState::Loading);
@@ -81,7 +78,9 @@ pub fn Browser(
<div class="browser">
{move || {
let Some(me) = me.get() else {
return view! {}.into_any();
return {
view! {}.into_any()
};
};
let loc_now = loc.get();
match effective_root(&me.roots, &loc_now) {
@@ -124,7 +123,9 @@ pub fn Browser(
}
.into_view()
.into_any(),
None => view! {}.into_any(),
None => {
view! {}.into_any()
},
}
}}
{move || {
@@ -135,12 +136,14 @@ pub fn Browser(
target=t
close=Callback::new(move |_| set_editor.set(None))
toast=toast
refresh=fetch.clone()
refresh=fetch
/>
}
.into_view()
.into_any(),
None => view! {}.into_any(),
None => {
view! {}.into_any()
},
}
}}
</div>
@@ -193,8 +196,8 @@ fn root_picker(
on:contextmenu=move |ev: MouseEvent| {
ev.prevent_default();
set_ctx.set(Some(CtxMenu {
x: ev.client_x() as i32,
y: ev.client_y() as i32,
x: ev.client_x(),
y: ev.client_y(),
entry: None,
}));
}
@@ -222,6 +225,7 @@ fn root_picker(
// File browser (breadcrumbs + toolbar + entries)
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)] // explicit signal props
fn file_browser(
root: &RootInfo,
loc: &ReadSignal<Location>,
@@ -334,8 +338,8 @@ fn file_browser(
on:contextmenu=move |ev: MouseEvent| {
ev.prevent_default();
set_ctx2.set(Some(CtxMenu {
x: ev.client_x() as i32,
y: ev.client_y() as i32,
x: ev.client_x(),
y: ev.client_y(),
entry: None,
}));
}
@@ -404,8 +408,8 @@ fn entry_actions(
ev.prevent_default();
ev.stop_propagation();
set_ctx.set(Some(CtxMenu {
x: ev.client_x() as i32,
y: ev.client_y() as i32,
x: ev.client_x(),
y: ev.client_y(),
entry: Some(entry2.clone()),
}));
};
@@ -454,8 +458,8 @@ fn grid_view(
}
}
});
let oc1 = open_cb.clone();
let oc2 = open_cb.clone();
let oc1 = open_cb;
let oc2 = open_cb;
view! {
<div
class="tile"
@@ -523,8 +527,8 @@ fn list_view(
}
}
});
let oc1 = open_cb.clone();
let oc2 = open_cb.clone();
let oc1 = open_cb;
let oc2 = open_cb;
view! {
<div
class="row"
@@ -568,14 +572,12 @@ fn CtxMenuView(
) -> impl IntoView {
// Close on any click or Escape.
{
let set_ctx = set_ctx;
let _h1 = window_event_listener(leptos::ev::click, move |_| {
set_ctx.set(None);
});
let set_ctx2 = set_ctx;
let _h2 = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
set_ctx2.set(None);
set_ctx.set(None);
}
});
}
@@ -583,7 +585,9 @@ fn CtxMenuView(
view! {
{move || {
let Some(m) = ctx.get() else {
return view! {}.into_any();
return {
view! {}.into_any()
};
};
// Keep the menu inside the viewport.
let (max_x, max_y) = web_sys::window()
@@ -889,7 +893,7 @@ fn action_new_folder(
show(toast, "Folder created");
refresh.run(());
}
Err(e) => show(toast, &e.to_string()),
Err(e) => show(toast, e.to_string()),
}
});
})
@@ -942,18 +946,19 @@ fn action_rename(
let f3 = f2.clone();
let n2 = new_name.clone();
spawn_local(async move {
match api::rename_item(root_id, &f3, n2, true).await {
match api::rename_item(root_id, &f3, n2, true).await
{
Ok(_) => {
show(toast, "Renamed");
refresh.run(());
}
Err(e2) => show(toast, &e2.to_string()),
Err(e2) => show(toast, e2.to_string()),
}
});
}),
}));
} else {
show(toast, &e.to_string());
show(toast, e.to_string());
}
}
}
@@ -1002,7 +1007,7 @@ fn action_delete(
show(toast, "Deleted");
refresh.run(());
}
Err(e) => show(toast, &e.to_string()),
Err(e) => show(toast, e.to_string()),
}
});
})
@@ -1031,7 +1036,10 @@ fn action_upload(
let dir2 = dir1.clone();
show(
toast,
&format!("Uploading {n} file{}\u{2026}", if n == 1 { "" } else { "s" }),
format!(
"Uploading {n} file{}\u{2026}",
if n == 1 { "" } else { "s" }
),
);
spawn_local(async move {
match api::upload(root_id, &dir2, false, files).await {
@@ -1054,13 +1062,13 @@ fn action_upload(
show(toast, "Upload complete");
refresh.run(());
}
Err(e2) => show(toast, &e2.to_string()),
Err(e2) => show(toast, e2.to_string()),
}
});
}),
}));
} else {
show(toast, &e.to_string());
show(toast, e.to_string());
}
}
}
@@ -1069,6 +1077,7 @@ fn action_upload(
})
}
#[allow(clippy::too_many_arguments)] // explicit signal props
fn action_move_copy(
op: Op,
entry: &Entry,
@@ -1099,58 +1108,68 @@ fn action_move_copy(
roots,
source_root: root_id,
source_dir: dir_str,
on_pick: owner.with(|| Callback::new(move |(dst_root, dst_dir): (i64, String)| {
set_dialog.set(None);
let f1 = full3.clone();
let f2 = full3.clone();
let name4 = name3.clone();
spawn_local(async move {
let res = if op == Op::Move {
api::move_item(root_id, &f1, dst_root, &dst_dir, false).await
} else {
api::copy_item(root_id, &f1, dst_root, &dst_dir, false).await
};
match res {
Ok(_) => {
show(toast, if op == Op::Move { "Moved" } else { "Copied" });
refresh.run(());
}
Err(e) => {
if is_conflict(&e) {
set_dialog.set(Some(Dialog::Conflict {
title: "Name already in use".into(),
files: vec![name4],
on_submit: Callback::new(move |_| {
let f3 = f2.clone();
let d2 = dst_dir.clone();
spawn_local(async move {
let res = if op == Op::Move {
api::move_item(root_id, &f3, dst_root, &d2, true)
on_pick: owner.with(|| {
Callback::new(move |(dst_root, dst_dir): (i64, String)| {
set_dialog.set(None);
let f1 = full3.clone();
let f2 = full3.clone();
let name4 = name3.clone();
spawn_local(async move {
let res = if op == Op::Move {
api::move_item(root_id, &f1, dst_root, &dst_dir, false).await
} else {
api::copy_item(root_id, &f1, dst_root, &dst_dir, false).await
};
match res {
Ok(_) => {
show(toast, if op == Op::Move { "Moved" } else { "Copied" });
refresh.run(());
}
Err(e) => {
if is_conflict(&e) {
set_dialog.set(Some(Dialog::Conflict {
title: "Name already in use".into(),
files: vec![name4],
on_submit: Callback::new(move |_| {
let f3 = f2.clone();
let d2 = dst_dir.clone();
spawn_local(async move {
let res = if op == Op::Move {
api::move_item(
root_id, &f3, dst_root, &d2, true,
)
.await
} else {
api::copy_item(root_id, &f3, dst_root, &d2, true)
} else {
api::copy_item(
root_id, &f3, dst_root, &d2, true,
)
.await
};
match res {
Ok(_) => {
show(
toast,
if op == Op::Move { "Moved" } else { "Copied" },
);
refresh.run(());
};
match res {
Ok(_) => {
show(
toast,
if op == Op::Move {
"Moved"
} else {
"Copied"
},
);
refresh.run(());
}
Err(e2) => show(toast, e2.to_string()),
}
Err(e2) => show(toast, &e2.to_string()),
}
});
}),
}));
} else {
show(toast, &e.to_string());
});
}),
}));
} else {
show(toast, e.to_string());
}
}
}
}
});
})),
});
})
}),
}));
})
}
Mweb/src/views/dialogs.rs
@@ -106,7 +106,9 @@ pub fn DialogView(
view! {
{move || {
let Some(d) = dialog.get() else {
return view! {}.into_any();
return {
view! {}.into_any()
};
};
let close = Callback::new(move |_| set_dialog.set(None));
match &d {
@@ -122,8 +124,8 @@ pub fn DialogView(
label.clone(),
initial.clone(),
submit.clone(),
on_submit.clone(),
close.clone(),
*on_submit,
close,
);
view! {
<PromptDialog
@@ -150,8 +152,8 @@ pub fn DialogView(
message.clone(),
submit.clone(),
*danger,
on_submit.clone(),
close.clone(),
*on_submit,
close,
);
view! {
<ConfirmDialog
@@ -174,8 +176,8 @@ pub fn DialogView(
let (title, files, on_submit, close) = (
title.clone(),
files.clone(),
on_submit.clone(),
close.clone(),
*on_submit,
close,
);
view! {
<ConflictDialog
@@ -202,8 +204,8 @@ pub fn DialogView(
roots.clone(),
*source_root,
source_dir.clone(),
on_pick.clone(),
close.clone(),
*on_pick,
close,
);
view! {
<PickerDialog
@@ -234,7 +236,7 @@ pub fn DialogView(
name=name
root_id=root_id
path=path
on_close=close.clone()
on_close=close
/>
}
.into_view()
@@ -255,7 +257,7 @@ pub fn DialogView(
entry=entry
root_name=root_name
rel_path=rel_path
on_close=close.clone()
on_close=close
/>
}
.into_view()
@@ -279,7 +281,7 @@ pub fn DialogView(
root_id=root_id
path=path
allow_writable=allow_writable
close=close.clone()
close=close
/>
}
.into_view()
@@ -466,12 +468,7 @@ fn PickerDialog(
set_ent.set(Vec::new());
spawn_local(async move {
match api::list_files(rid, &dir).await {
Ok(r) => set_ent.set(
r.entries
.into_iter()
.filter(|e| e.is_dir)
.collect(),
),
Ok(r) => set_ent.set(r.entries.into_iter().filter(|e| e.is_dir).collect()),
Err(_) => set_ent.set(Vec::new()),
}
});
@@ -664,10 +661,8 @@ fn DownloadFormatDialog(
<div class="dl-formats">
{formats.iter().map(|(id, label, ext)| {
let (id, label, ext) = (*id, *label, *ext);
let root_id = root_id;
let path = path.clone();
let name = name.clone();
let on_close = on_close.clone();
view! {
<button
class="btn btn-primary dl-fmt"