Collapse duplicated code, give the db one error contract

Follow-ups from an architecture review of the codebase.

- CtxMenuView built a Vec of (String, Option<Callback>, Option<String>)
  tuples over 265 lines, with ~15 redundant aliases of Copy signals and a
  clone of the entry per item. It is now a MenuItem struct with `new` and
  `rw` constructors, the shown root is resolved once, and the inline "Open"
  closure became action_open_dir like its siblings.
- Rename, move/copy and upload each hand-rolled "on 409, offer overwrite,
  retry". One run_with_overwrite_retry helper now covers all three.
- db.rs returned Result from some methods and swallowed errors in others.
  A failing user_roots query rendered as "No folders available" rather than
  an error. Every method returns DbResult now; ApiError already converts
  from rusqlite::Error, so handlers just gained `?`.
- POST /api/files fell through to mkdir for any unrecognized content type,
  so a typo in a header silently created a folder. mkdir now names itself
  with ?action=mkdir and anything else is a 415.
- Two different validate_name functions became validate_component (one path
  segment) and validate_account_name (a user name).
- Four copies of "SystemTime to unix seconds" became fs::mtime_secs.
- The web client hand-rolled JSON via serde-wasm-bindgen plus
  JSON.stringify, with a string-scanning fallback for error bodies. It uses
  serde_json now: one JSON implementation, and file sizes no longer
  round-trip through an f64.
- Removed the README, which described a feature set the code had outgrown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit73f28a61487f563e69140f86167f4abd1aed1a25
Parent7abefd3
17 files changed, 549 insertions(+), 694 deletions(-)
▾MCargo.lock
@@ -2130,17 +2130,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "serde_core"
version = "1.0.229"
@@ -3027,7 +3016,7 @@ dependencies = [
"js-sys",
"leptos",
"serde",
"serde-wasm-bindgen",
"serde_json",
"thiserror 2.0.20",
"wasm-bindgen",
"wasm-bindgen-futures",
▾DREADME.md
-59
@@ -1,59 +0,0 @@
# filebrowser-ng
A simple, noob-friendly web file browser. Single binary, SQLite-backed.
## Features (status)
- [x] First-boot admin setup (create the admin account in the browser)
- [x] Login/logout with cookie sessions (argon2id password hashing)
- [x] Per-user folder access ("virtual roots"), read-write or read-only
- [ ] File browsing (grid/list, breadcrumbs, context menu)
- [ ] Upload (files & folders, drag & drop, overwrite warning)
- [ ] Download (files as-is; folders as zip / tar / tar.gz / tar.zst, streamed)
- [ ] Move / copy / rename / delete / new folder
- [ ] File info, previews (image/PDF/video/audio/text), text editor (CodeMirror)
- [ ] Shares (token links, optional expiry, optional writable, admin toggle)
- [ ] Admin UI (user management, settings)
## Usage
```
filebrowser-ng --root /path/to/files --db /path/to/filebrowser.db
[--port 8080] [--bind 127.0.0.1] [--https]
```
- `--root` — the folder the server has access to (required)
- `--db` — path to the SQLite database (required)
- `--https` — assume a TLS-terminating reverse proxy in front (Secure cookies)
First run: open the URL and create the admin account. The admin gets the
whole root as their folder.
## Development
```sh
just dev-server # backend on :8081 (needs the dev data dir; created automatically)
just dev-web # Trunk dev server on :8080, proxies /api to :8081
```
Open http://localhost:8080 .
Production single binary:
```sh
just build # trunk build + embed into server/dist + cargo build --release
just run # build, then run against .dev/root and .dev/db.sqlite
```
Reset dev users/settings: `just reset-db`
## Architecture
- `server/` — Axum (Rust). SQLite via `rusqlite` (bundled). All user paths are
stored **relative to `--root`**; every filesystem operation resolves
`<root>/<user-root>/<requested-path>`, canonicalizes it, and verifies it is
still inside the user's root (blocks `..` and symlink escapes). Writes
additionally require the root to be `rw`.
- `web/` — Leptos 0.8 CSR, built with Trunk. In production the frontend is
embedded into the binary (`--features embedded`, folder `server/dist`); in
dev it is served from `web/dist` on disk.
▾Mapi-types/src/lib.rs
@@ -33,6 +33,9 @@ pub const P_ACTION: &str = "action";
pub const ACTION_DOWNLOAD: &str = "download";
pub const ACTION_PREVIEW: &str = "preview";
pub const ACTION_CONTENT: &str = "content";
/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
/// POST route also carries uploads and mutations.
pub const ACTION_MKDIR: &str = "mkdir";
/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
pub const P_FORMAT: &str = "format";
/// `?share=<token>` — authenticate file calls with a public share token.
▾Mjustfile
@@ -45,7 +45,7 @@ build-cm:
cd web && bun install --frozen-lockfile && bun run build
# Run the production binary against the dev folders.
run: build
run: dev-data build
./target/release/filebrowser-ng --root {{dev-root}} --db {{dev-db}}
# Server test suite (unit + API integration tests).
▾Mserver/src/api/admin.rs
@@ -9,7 +9,7 @@ use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use crate::api::common::AdminUser as AdminGuard;
use crate::api::common::{display_name, hash_password, validate_name, validate_password};
use crate::api::common::{display_name, hash_password, validate_account_name, validate_password};
use crate::db::Db;
use crate::error::{ApiError, AppState};
use crate::fs;
@@ -27,15 +27,19 @@ fn root_info(state: &AppState, r: &crate::db::RootRow) -> RootInfo {
}
}
async fn user_info(db: &Db, state: &AppState, user: &crate::db::User) -> AdminUser {
let roots = db.user_roots(user.id).await;
AdminUser {
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 {
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
@@ -73,10 +77,10 @@ pub async fn list_users(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
) -> Result<Json<Vec<AdminUser>>, ApiError> {
let users = state.db.all_users().await;
let users = state.db.all_users().await?;
let mut out = Vec::with_capacity(users.len());
for u in &users {
out.push(user_info(&state.db, &state, u).await);
out.push(user_info(&state.db, &state, u).await?);
}
Ok(Json(out))
}
@@ -88,9 +92,9 @@ pub async fn create_user(
Json(body): Json<CreateUser>,
) -> Result<Json<AdminUser>, ApiError> {
let name = body.name.trim().to_string();
validate_name(&name)?;
validate_account_name(&name)?;
validate_password(&body.password)?;
if state.db.find_user_by_name(&name).await.is_some() {
if state.db.find_user_by_name(&name).await?.is_some() {
return Err(ApiError::new(
StatusCode::CONFLICT,
"a user with that name already exists",
@@ -103,7 +107,7 @@ 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))
Ok(Json(user_info(&state.db, &state, &user).await?))
}
/// PUT /api/admin/users/{id} — update a user (password / is_admin / active /
@@ -117,7 +121,7 @@ pub async fn update_user(
let target = state
.db
.find_user_by_id(id)
.await
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
// Lockout guards: an admin cannot demote, disable, or delete themselves.
@@ -139,7 +143,7 @@ pub async fn update_user(
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 {
if (demoting || disabling) && state.db.count_admins().await? <= 1 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"cannot remove the last active admin",
@@ -165,9 +169,9 @@ pub async fn update_user(
let updated = state
.db
.find_user_by_id(id)
.await
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
Ok(Json(user_info(&state.db, &state, &updated).await))
Ok(Json(user_info(&state.db, &state, &updated).await?))
}
/// DELETE /api/admin/users/{id} — delete a user (not yourself).
@@ -185,15 +189,15 @@ pub async fn delete_user(
let target = state
.db
.find_user_by_id(id)
.await
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
if target.is_admin && target.active && state.db.count_admins().await <= 1 {
if target.is_admin && target.active && state.db.count_admins().await? <= 1 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"cannot delete the last active admin",
));
}
if !state.db.delete_user(id).await {
if !state.db.delete_user(id).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found"));
}
Ok(Json(OkResp { ok: true }))
@@ -205,7 +209,7 @@ pub async fn get_settings(
_admin: AdminGuard,
) -> Result<Json<Settings>, ApiError> {
Ok(Json(Settings {
allow_writable_shares: state.db.allow_writable_shares().await,
allow_writable_shares: state.db.allow_writable_shares().await?,
}))
}
▾Mserver/src/api/auth.rs
@@ -6,7 +6,7 @@ use axum::extract::State;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use crate::api::common::{display_name, hash_password, validate_name, validate_password};
use crate::api::common::{display_name, hash_password, validate_account_name, validate_password};
use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie};
use crate::error::{ApiError, AppState};
@@ -19,7 +19,7 @@ pub async fn me(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<Me>, ApiError> {
if state.db.user_count().await == 0 {
if state.db.user_count().await? == 0 {
return Ok(Json(Me {
first_boot: true,
user: None,
@@ -31,7 +31,7 @@ pub async fn me(
let Some(token) = parse_session_cookie(&headers) else {
return Err(ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"));
};
let Some(user) = state.db.session_user(&token).await else {
let Some(user) = state.db.session_user(&token).await? else {
return Err(ApiError::new(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
@@ -41,7 +41,7 @@ pub async fn me(
let roots: Vec<RootInfo> = state
.db
.user_roots(user.id)
.await
.await?
.into_iter()
.map(|r| RootInfo {
id: r.id,
@@ -59,7 +59,7 @@ pub async fn me(
is_admin: user.is_admin,
}),
roots,
allow_writable_shares: state.db.allow_writable_shares().await,
allow_writable_shares: state.db.allow_writable_shares().await?,
}))
}
@@ -70,9 +70,9 @@ pub async fn setup(
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let name = body.name.trim();
validate_name(name)?;
validate_account_name(name)?;
validate_password(&body.password)?;
if state.db.user_count().await > 0 {
if state.db.user_count().await? > 0 {
return Err(ApiError::new(
StatusCode::CONFLICT,
"server is already set up",
@@ -98,7 +98,7 @@ pub async fn login(
State(state): State<Arc<AppState>>,
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let Some(user) = state.db.verify_password(&body.name, &body.password).await else {
let Some(user) = state.db.verify_password(&body.name, &body.password).await? else {
return Err(ApiError::new(
StatusCode::UNAUTHORIZED,
"invalid name or password",
▾Mserver/src/api/common.rs
@@ -38,7 +38,7 @@ where
// 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 {
return match state.db.share_by_token(&share_token).await? {
Some(share) if !share.is_expired() => {
let roots = vec![RootRow {
id: share.id,
@@ -97,13 +97,13 @@ async fn session_auth(parts: &Parts, state: &AppState) -> Result<(User, Vec<Root
let Some(token) = parse_session_cookie(&parts.headers) else {
return Err(ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"));
};
let Some(user) = state.db.session_user(&token).await else {
let Some(user) = state.db.session_user(&token).await? else {
return Err(ApiError::new(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
));
};
let roots = state.db.user_roots(user.id).await;
let roots = state.db.user_roots(user.id).await?;
Ok((user, roots))
}
@@ -157,7 +157,7 @@ pub(crate) fn display_name(server_root: &std::path::Path, rel: &str) -> String {
.unwrap_or_else(|| rel.to_string())
}
pub(crate) fn validate_name(name: &str) -> Result<(), ApiError> {
pub(crate) fn validate_account_name(name: &str) -> Result<(), ApiError> {
let n = name.trim();
if n.is_empty() || n.len() > 64 {
return Err(ApiError::new(
▾Mserver/src/api/files.rs
@@ -12,7 +12,6 @@
use std::io::{self, Read};
use std::path::Component;
use std::sync::Arc;
use std::time::UNIX_EPOCH;
use axum::Json;
use axum::extract::{Path as AxumPath, Query as AxumQuery, State};
@@ -30,7 +29,7 @@ use crate::archive::{self, ArchiveFormat};
use crate::db::{RootRow, ShareRow};
use crate::error::{ApiError, AppState};
use crate::fs::{self, FsError};
use api_types::{FilesResp, Mutation, OkResp, Op, P_OVERWRITE, SaveResp, UploadResp};
use api_types::{FilesResp, Mutation, OkResp, Op, P_ACTION, P_OVERWRITE, SaveResp, UploadResp};
/// Upper bound for the in-memory text endpoint (preview, later editor).
const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
@@ -246,12 +245,7 @@ async fn content(
let meta = tokio::fs::metadata(&full)
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?;
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let mtime = fs::mtime_secs(&meta).unwrap_or(0);
Ok((
[
(
@@ -462,6 +456,12 @@ pub async fn dispatch(
dispatch_inner(state, auth, root_id, req_rel, headers, req).await
}
/// Route one POST to upload, mutation or mkdir.
///
/// 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>,
auth: AuthUser,
@@ -489,7 +489,13 @@ async fn dispatch_inner(
.await?
.into_response());
}
Ok(mkdir(state, auth, root_id, req_rel).await?.into_response())
if action_param(req.uri()).as_deref() == Some(api_types::ACTION_MKDIR) {
return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
}
Err(ApiError::new(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
))
}
// ---------------------------------------------------------------------------
@@ -762,15 +768,22 @@ fn parse_boundary(content_type: &str) -> Option<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 {
uri.query()
.map(|q| {
let t = format!("{P_OVERWRITE}=true");
let o = format!("{P_OVERWRITE}=1");
q.split('&').any(|kv| kv == t || kv == o)
})
.unwrap_or(false)
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 {
▾Mserver/src/api/shares.rs
@@ -44,7 +44,7 @@ pub async fn list(
State(state): State<Arc<AppState>>,
auth: SessionUser,
) -> Result<Json<Vec<ShareInfo>>, ApiError> {
let rows = state.db.user_shares(auth.user.id).await;
let rows = state.db.user_shares(auth.user.id).await?;
Ok(Json(
rows.iter().map(|r| share_info(r, &state.root)).collect(),
))
@@ -56,7 +56,7 @@ pub async fn create(
auth: SessionUser,
Json(body): Json<CreateShare>,
) -> Result<Json<ShareInfo>, ApiError> {
if body.writable && !state.db.allow_writable_shares().await {
if body.writable && !state.db.allow_writable_shares().await? {
return Err(ApiError::new(
StatusCode::FORBIDDEN,
"writable shares are disabled",
@@ -117,7 +117,7 @@ pub async fn delete(
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
if !state.db.delete_share(id, auth.user.id).await {
if !state.db.delete_share(id, auth.user.id).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
}
Ok(Json(OkResp { ok: true }))
@@ -128,7 +128,7 @@ pub async fn resolve(
State(state): State<Arc<AppState>>,
AxumPath(token): AxumPath<String>,
) -> Result<Json<ShareInfo>, ApiError> {
let Some(row) = state.db.share_by_token(&token).await else {
let Some(row) = state.db.share_by_token(&token).await? else {
return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
};
if row.is_expired() {
▾Mserver/src/archive.rs
@@ -7,7 +7,6 @@
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
/// The archive formats offered for folder downloads.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -75,11 +74,10 @@ pub fn build(
fn mtime_secs(p: &Path) -> u64 {
std::fs::metadata(p)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.and_then(|m| crate::fs::mtime_secs(&m))
.unwrap_or(0)
.max(0) as u64
}
/// Cycle guard: a symlink loop would otherwise recurse forever. Real trees
▾Mserver/src/db.rs
@@ -69,6 +69,13 @@ impl ShareRow {
}
}
/// Every query can fail, and every caller decides what to do about it.
///
/// Earlier versions swallowed read errors and returned a default (an empty
/// root list, a count of 0). That turned a broken database into a plausible
/// answer: "you have no folders" instead of an error. One contract now.
pub type DbResult<T> = Result<T, rusqlite::Error>;
#[derive(Clone)]
pub struct Db(Arc<tokio::sync::Mutex<Connection>>);
@@ -134,15 +141,14 @@ impl Db {
// ---------- users ----------
pub async fn user_count(&self) -> i64 {
pub async fn user_count(&self) -> DbResult<i64> {
let c = self.0.lock().await;
c.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
.unwrap_or(0)
}
/// Create the first admin account with the whole root visible (read-write).
/// Only valid while no users exist (enforced by the caller).
pub async fn create_admin(&self, name: &str, pass_hash: &str) -> Result<User, rusqlite::Error> {
pub async fn create_admin(&self, name: &str, pass_hash: &str) -> DbResult<User> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
tx.execute(
@@ -164,7 +170,7 @@ impl Db {
})
}
pub async fn verify_password(&self, name: &str, password: &str) -> Option<User> {
pub async fn verify_password(&self, name: &str, password: &str) -> DbResult<Option<User>> {
// The guard is scoped to the query alone. Argon2 below is slow by
// design; holding the single connection lock across it would make one
// login serialize every other database access.
@@ -175,13 +181,13 @@ impl Db {
[name],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)
.optional()
.ok()
.flatten()
.optional()?
};
let Some((id, name, is_admin, hash, active)) = row else {
return Ok(None);
};
let (id, name, is_admin, hash, active) = row?;
if !active {
return None;
return Ok(None);
}
// Argon2 is CPU-bound, so it must not run on an async worker thread.
let password = password.to_string();
@@ -189,17 +195,17 @@ impl Db {
tokio::task::spawn_blocking(move || crate::auth::verify_password(&password, &hash))
.await
.unwrap_or(false);
ok.then_some(User {
Ok(ok.then_some(User {
id,
name,
is_admin,
active,
})
}))
}
// ---------- sessions ----------
pub async fn create_session(&self, user_id: i64, token: &str) -> Result<(), rusqlite::Error> {
pub async fn create_session(&self, user_id: i64, token: &str) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"INSERT INTO sessions (token, user_id, created_at, last_seen_at)
@@ -209,116 +215,77 @@ impl Db {
Ok(())
}
pub async fn delete_session(&self, token: &str) -> Result<(), rusqlite::Error> {
pub async fn delete_session(&self, token: &str) -> DbResult<()> {
let c = self.0.lock().await;
c.execute("DELETE FROM sessions WHERE token = ?1", [token])?;
Ok(())
}
pub async fn session_user(&self, token: &str) -> Option<User> {
pub async fn session_user(&self, token: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?1 AND u.active = 1",
[token],
|r| {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
is_admin: r.get(2)?,
active: r.get(3)?,
})
},
map_user,
)
.ok()
.optional()
}
// ---------- roots ----------
pub async fn user_roots(&self, user_id: i64) -> Vec<RootRow> {
pub async fn user_roots(&self, user_id: i64) -> DbResult<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")
else {
return out;
};
if let Ok(rows) = stmt.query_map([user_id], |r| {
let mut stmt =
c.prepare("SELECT id, path, mode FROM user_roots WHERE user_id = ?1 ORDER BY id")?;
let rows = stmt.query_map([user_id], |r| {
Ok(RootRow {
id: r.get(0)?,
path: r.get(1)?,
mode: r.get::<_, SqlMode>(2)?.0,
})
}) {
out.extend(rows.flatten());
}
out
})?;
rows.collect()
}
// ---------- admin: user management (M7) ----------
pub async fn all_users(&self) -> Vec<User> {
pub async fn all_users(&self) -> DbResult<Vec<User>> {
let c = self.0.lock().await;
let mut out = Vec::new();
let sql = "SELECT id, name, is_admin != 0, active != 0 FROM users ORDER BY id";
if let Ok(mut stmt) = c.prepare(sql)
&& let Ok(rows) = stmt.query_map([], |r| {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
is_admin: r.get(2)?,
active: r.get(3)?,
})
})
{
out.extend(rows.flatten());
}
out
let mut stmt =
c.prepare("SELECT id, name, is_admin != 0, active != 0 FROM users ORDER BY id")?;
let rows = stmt.query_map([], map_user)?;
rows.collect()
}
pub async fn find_user_by_id(&self, id: i64) -> Option<User> {
pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0 FROM users WHERE id = ?1",
[id],
|r| {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
is_admin: r.get(2)?,
active: r.get(3)?,
})
},
map_user,
)
.ok()
.optional()
}
pub async fn find_user_by_name(&self, name: &str) -> Option<User> {
pub async fn find_user_by_name(&self, name: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0 FROM users WHERE name = ?1",
[name],
|r| {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
is_admin: r.get(2)?,
active: r.get(3)?,
})
},
map_user,
)
.ok()
.optional()
}
pub async fn count_admins(&self) -> i64 {
pub async fn count_admins(&self) -> DbResult<i64> {
let c = self.0.lock().await;
c.query_row(
"SELECT COUNT(*) FROM users WHERE is_admin = 1 AND active = 1",
[],
|r| r.get(0),
)
.unwrap_or(0)
}
/// Create a user with the given roots (path, mode) pairs.
@@ -328,7 +295,7 @@ impl Db {
pass_hash: &str,
is_admin: bool,
roots: &[(String, Mode)],
) -> Result<User, rusqlite::Error> {
) -> DbResult<User> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
tx.execute(
@@ -352,11 +319,7 @@ impl Db {
})
}
pub async fn update_user_password(
&self,
id: i64,
pass_hash: &str,
) -> Result<(), rusqlite::Error> {
pub async fn update_user_password(&self, id: i64, pass_hash: &str) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET pass_hash = ?1 WHERE id = ?2",
@@ -365,7 +328,7 @@ impl Db {
Ok(())
}
pub async fn set_user_admin(&self, id: i64, is_admin: bool) -> Result<(), rusqlite::Error> {
pub async fn set_user_admin(&self, id: i64, is_admin: bool) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET is_admin = ?1 WHERE id = ?2",
@@ -374,7 +337,7 @@ impl Db {
Ok(())
}
pub async fn set_user_active(&self, id: i64, active: bool) -> Result<(), rusqlite::Error> {
pub async fn set_user_active(&self, id: i64, active: bool) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET active = ?1 WHERE id = ?2",
@@ -383,20 +346,14 @@ impl Db {
Ok(())
}
pub async fn delete_user(&self, id: i64) -> bool {
/// Delete a user. `false` means no row matched.
pub async fn delete_user(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
c.execute("DELETE FROM users WHERE id = ?1", [id])
.ok()
.map(|n| n > 0)
.unwrap_or(false)
Ok(c.execute("DELETE FROM users WHERE id = ?1", [id])? > 0)
}
/// Replace a user's roots with the given (path, mode) pairs.
pub async fn set_user_roots(
&self,
user_id: i64,
roots: &[(String, Mode)],
) -> Result<(), rusqlite::Error> {
pub async fn set_user_roots(&self, user_id: i64, roots: &[(String, Mode)]) -> DbResult<()> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
tx.execute("DELETE FROM user_roots WHERE user_id = ?1", [user_id])?;
@@ -420,7 +377,7 @@ impl Db {
is_file: bool,
mode: Mode,
expires_at: Option<&str>,
) -> Result<ShareRow, rusqlite::Error> {
) -> DbResult<ShareRow> {
let c = self.0.lock().await;
c.execute(
"INSERT INTO shares (token, creator_id, target, is_file, mode, created_at, expires_at)
@@ -448,48 +405,43 @@ impl Db {
})
}
pub async fn share_by_token(&self, token: &str) -> Option<ShareRow> {
pub async fn share_by_token(&self, token: &str) -> DbResult<Option<ShareRow>> {
let c = self.0.lock().await;
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
FROM shares WHERE token = ?1";
c.query_row(sql, [token], map_share).ok()
c.query_row(sql, [token], map_share).optional()
}
pub async fn user_shares(&self, creator_id: i64) -> Vec<ShareRow> {
pub async fn user_shares(&self, creator_id: i64) -> DbResult<Vec<ShareRow>> {
let c = self.0.lock().await;
let mut out = Vec::new();
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
FROM shares WHERE creator_id = ?1 ORDER BY id DESC";
if let Ok(mut stmt) = c.prepare(sql)
&& let Ok(rows) = stmt.query_map([creator_id], map_share)
{
out.extend(rows.flatten());
}
out
let mut stmt = c.prepare(sql)?;
let rows = stmt.query_map([creator_id], map_share)?;
rows.collect()
}
pub async fn delete_share(&self, id: i64, creator_id: i64) -> bool {
/// Delete one of `creator_id`'s shares. `false` means no row matched.
pub async fn delete_share(&self, id: i64, creator_id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
c.execute(
let n = c.execute(
"DELETE FROM shares WHERE id = ?1 AND creator_id = ?2",
params![id, creator_id],
)
.ok()
.map(|n| n > 0)
.unwrap_or(false)
)?;
Ok(n > 0)
}
// ---------- settings ----------
pub async fn get_setting(&self, key: &str) -> Option<String> {
pub async fn get_setting(&self, key: &str) -> DbResult<Option<String>> {
let c = self.0.lock().await;
c.query_row("SELECT value FROM settings WHERE key = ?1", [key], |r| {
r.get(0)
})
.ok()
.optional()
}
pub async fn set_setting(&self, key: &str, value: &str) -> Result<(), rusqlite::Error> {
pub async fn set_setting(&self, key: &str, value: &str) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)
@@ -501,18 +453,28 @@ impl Db {
/// Whether users may create writable (read-write) shares. Off by default;
/// the admin setting gates it.
pub async fn allow_writable_shares(&self) -> bool {
self.get_setting("allow_writable_shares").await.as_deref() == Some("1")
pub async fn allow_writable_shares(&self) -> DbResult<bool> {
Ok(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) -> DbResult<()> {
self.set_setting("allow_writable_shares", if v { "1" } else { "0" })
.await
}
}
/// Column order matched by the four `users` SELECTs above.
fn map_user(r: &rusqlite::Row) -> DbResult<User> {
Ok(User {
id: r.get(0)?,
name: r.get(1)?,
is_admin: r.get(2)?,
active: r.get(3)?,
})
}
/// Column order matched by the two `shares` SELECTs above.
fn map_share(r: &rusqlite::Row) -> rusqlite::Result<ShareRow> {
fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
Ok(ShareRow {
id: r.get(0)?,
token: r.get(1)?,
@@ -591,12 +553,12 @@ mod tests {
#[tokio::test]
async fn fresh_db_state() {
let db = mem().await;
assert_eq!(db.user_count().await, 0);
assert_eq!(db.count_admins().await, 0);
assert!(!db.allow_writable_shares().await);
assert!(db.find_user_by_name("nobody").await.is_none());
assert!(db.find_user_by_id(1).await.is_none());
assert!(db.all_users().await.is_empty());
assert_eq!(db.user_count().await.unwrap(), 0);
assert_eq!(db.count_admins().await.unwrap(), 0);
assert!(!db.allow_writable_shares().await.unwrap());
assert!(db.find_user_by_name("nobody").await.unwrap().is_none());
assert!(db.find_user_by_id(1).await.unwrap().is_none());
assert!(db.all_users().await.unwrap().is_empty());
}
#[tokio::test]
@@ -619,15 +581,21 @@ mod tests {
.unwrap();
}
let db = Db::open(&path).await.unwrap();
assert_eq!(db.user_count().await, 1);
let u = db.find_user_by_name("legacy").await.unwrap();
assert_eq!(db.user_count().await.unwrap(), 1);
let u = db.find_user_by_name("legacy").await.unwrap().unwrap();
assert!(u.active, "v2 migration must default active to true");
assert!(u.is_admin);
assert_eq!(db.user_roots(u.id).await.len(), 1);
assert_eq!(db.user_roots(u.id).await.unwrap().len(), 1);
// Migrations are idempotent.
let db2 = Db::open(&path).await.unwrap();
assert_eq!(db2.user_count().await, 1);
assert!(db2.find_user_by_name("legacy").await.unwrap().active);
assert_eq!(db2.user_count().await.unwrap(), 1);
assert!(
db2.find_user_by_name("legacy")
.await
.unwrap()
.unwrap()
.active
);
}
#[tokio::test]
@@ -636,36 +604,56 @@ mod tests {
assert!(admin.is_admin);
assert!(admin.active);
// Root "." rw is assigned by create_admin.
let roots = db.user_roots(admin.id).await;
let roots = db.user_roots(admin.id).await.unwrap();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].path, ".");
assert_eq!(roots[0].mode, Mode::Rw);
assert!(db.verify_password("admin", "admin1234").await.is_some());
assert!(db.verify_password("admin", "nope").await.is_none());
assert!(
db.verify_password("admin", "admin1234")
.await
.unwrap()
.is_some()
);
assert!(db.verify_password("admin", "nope").await.unwrap().is_none());
// Name lookup is case-insensitive (COLLATE NOCASE).
assert!(db.verify_password("ADMIN", "admin1234").await.is_some());
assert!(
db.verify_password("ADMIN", "admin1234")
.await
.unwrap()
.is_some()
);
// Disabled users cannot verify.
db.set_user_active(admin.id, false).await.unwrap();
assert!(db.verify_password("admin", "admin1234").await.is_none());
assert!(
db.verify_password("admin", "admin1234")
.await
.unwrap()
.is_none()
);
db.set_user_active(admin.id, true).await.unwrap();
assert!(db.verify_password("admin", "admin1234").await.is_some());
assert!(
db.verify_password("admin", "admin1234")
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn sessions_lifecycle() {
let (db, admin) = db_with_admin().await;
assert!(db.session_user("ghost-token").await.is_none());
assert!(db.session_user("ghost-token").await.unwrap().is_none());
db.create_session(admin.id, "tok1").await.unwrap();
let u = db.session_user("tok1").await.unwrap();
let u = db.session_user("tok1").await.unwrap().unwrap();
assert_eq!(u.id, admin.id);
// Disabling the user invalidates existing sessions.
db.set_user_active(admin.id, false).await.unwrap();
assert!(db.session_user("tok1").await.is_none());
assert!(db.session_user("tok1").await.unwrap().is_none());
db.set_user_active(admin.id, true).await.unwrap();
assert!(db.session_user("tok1").await.is_some());
assert!(db.session_user("tok1").await.unwrap().is_some());
db.delete_session("tok1").await.unwrap();
assert!(db.session_user("tok1").await.is_none());
assert!(db.session_user("tok1").await.unwrap().is_none());
}
#[tokio::test]
@@ -685,41 +673,57 @@ mod tests {
assert!(db.create_user("carol", &h2, false, &[]).await.is_ok());
// Lookup helpers.
assert_eq!(db.find_user_by_name("Bob").await.unwrap().id, bob.id);
assert_eq!(db.find_user_by_id(bob.id).await.unwrap().name, "bob");
assert!(db.find_user_by_name("dave").await.is_none());
assert_eq!(db.all_users().await.len(), 3);
assert_eq!(
db.find_user_by_name("Bob").await.unwrap().unwrap().id,
bob.id
);
assert_eq!(
db.find_user_by_id(bob.id).await.unwrap().unwrap().name,
"bob"
);
assert!(db.find_user_by_name("dave").await.unwrap().is_none());
assert_eq!(db.all_users().await.unwrap().len(), 3);
// Root replacement semantics.
let roots = db.user_roots(bob.id).await;
let roots = db.user_roots(bob.id).await.unwrap();
assert_eq!(roots.len(), 1);
db.set_user_roots(bob.id, &[(".".into(), Mode::Ro), ("docs".into(), Mode::Rw)])
.await
.unwrap();
let roots = db.user_roots(bob.id).await;
let roots = db.user_roots(bob.id).await.unwrap();
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|r| r.path == "." && r.mode == Mode::Ro));
db.set_user_roots(bob.id, &[]).await.unwrap();
assert!(db.user_roots(bob.id).await.is_empty());
assert!(db.user_roots(bob.id).await.unwrap().is_empty());
// Password update.
let new_h = crate::auth::hash_password("bobpass2").unwrap();
db.update_user_password(bob.id, &new_h).await.unwrap();
assert!(db.verify_password("bob", "bobpass1").await.is_none());
assert!(db.verify_password("bob", "bobpass2").await.is_some());
assert!(
db.verify_password("bob", "bobpass1")
.await
.unwrap()
.is_none()
);
assert!(
db.verify_password("bob", "bobpass2")
.await
.unwrap()
.is_some()
);
// Admin flag + count (only active admins count).
db.set_user_admin(bob.id, true).await.unwrap();
assert_eq!(db.count_admins().await, 2);
assert_eq!(db.count_admins().await.unwrap(), 2);
db.set_user_active(bob.id, false).await.unwrap();
assert_eq!(db.count_admins().await, 1);
assert_eq!(db.count_admins().await.unwrap(), 1);
db.set_user_admin(bob.id, false).await.unwrap();
// Deletion.
assert!(db.delete_user(bob.id).await);
assert!(db.find_user_by_id(bob.id).await.is_none());
assert!(!db.delete_user(bob.id).await);
assert_eq!(db.user_count().await, 2);
assert!(db.delete_user(bob.id).await.unwrap());
assert!(db.find_user_by_id(bob.id).await.unwrap().is_none());
assert!(!db.delete_user(bob.id).await.unwrap());
assert_eq!(db.user_count().await.unwrap(), 2);
}
fn share_row(expires_at: Option<&str>) -> ShareRow {
@@ -764,39 +768,42 @@ mod tests {
.unwrap();
assert!(s2.id > s1.id);
let found = db.share_by_token("tok-b").await.unwrap();
let found = db.share_by_token("tok-b").await.unwrap().unwrap();
assert!(found.is_file);
assert_eq!(found.mode, Mode::Rw);
assert!(db.share_by_token("nope").await.is_none());
assert!(db.share_by_token("nope").await.unwrap().is_none());
// Listed newest-first.
let list = db.user_shares(admin.id).await;
let list = db.user_shares(admin.id).await.unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].id, s2.id);
// Other users see nothing.
let h = crate::auth::hash_password("bobpass1").unwrap();
let bob = db.create_user("bob", &h, false, &[]).await.unwrap();
assert!(db.user_shares(bob.id).await.is_empty());
assert!(db.user_shares(bob.id).await.unwrap().is_empty());
// Only the creator can delete.
assert!(!db.delete_share(s1.id, bob.id).await);
assert!(db.delete_share(s1.id, admin.id).await);
assert!(db.share_by_token("tok-a").await.is_none());
assert!(!db.delete_share(s1.id, admin.id).await);
assert!(!db.delete_share(s1.id, bob.id).await.unwrap());
assert!(db.delete_share(s1.id, admin.id).await.unwrap());
assert!(db.share_by_token("tok-a").await.unwrap().is_none());
assert!(!db.delete_share(s1.id, admin.id).await.unwrap());
}
#[tokio::test]
async fn settings_round_trip() {
let (db, _admin) = db_with_admin().await;
assert!(!db.allow_writable_shares().await);
assert!(!db.allow_writable_shares().await.unwrap());
db.set_allow_writable_shares(true).await.unwrap();
assert!(db.allow_writable_shares().await);
assert!(db.allow_writable_shares().await.unwrap());
// Upsert semantics.
db.set_allow_writable_shares(false).await.unwrap();
assert!(!db.allow_writable_shares().await);
assert!(!db.allow_writable_shares().await.unwrap());
// Generic get/set.
db.set_setting("custom", "v").await.unwrap();
assert_eq!(db.get_setting("custom").await.as_deref(), Some("v"));
assert_eq!(db.get_setting("missing").await, None);
assert_eq!(
db.get_setting("custom").await.unwrap().as_deref(),
Some("v")
);
assert_eq!(db.get_setting("missing").await.unwrap(), None);
}
}
▾Mserver/src/fs.rs
@@ -313,12 +313,18 @@ fn looks_like_text(head: &[u8]) -> bool {
/// Reported when a file's modification time is unavailable or unrepresentable.
const EPOCH_MTIME: &str = "1970-01-01T00:00:00Z";
fn mtime_str(m: &std::fs::Metadata) -> String {
let dt: Option<DateTime<chrono::Utc>> = m
.modified()
/// A file's modification time in whole unix seconds, or `None` when the
/// platform cannot report one. The single place that converts a `SystemTime`.
pub fn mtime_secs(m: &std::fs::Metadata) -> Option<i64> {
m.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, 0));
.map(|d| d.as_secs() as i64)
}
fn mtime_str(m: &std::fs::Metadata) -> String {
let dt: Option<DateTime<chrono::Utc>> =
mtime_secs(m).and_then(|s| DateTime::from_timestamp(s, 0));
dt.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_else(|| EPOCH_MTIME.to_string())
}
@@ -338,7 +344,7 @@ pub fn resolve_dir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<
}
/// Validate a new single-component name (for rename / new folder).
fn validate_name(name: &str) -> Result<(), FsError> {
fn validate_component(name: &str) -> Result<(), FsError> {
let p = Path::new(name);
if name.is_empty()
|| p.components().count() != 1
@@ -393,7 +399,7 @@ pub fn rename_item(
new_name: &str,
overwrite: bool,
) -> Result<(), FsError> {
validate_name(new_name)?;
validate_component(new_name)?;
let from = resolve_path(server_root, root_rel, req_rel)?;
let parent = from
.parent()
@@ -445,26 +451,15 @@ pub fn save_file(
return Err(FsError::NotADirectory);
}
if let Some(expected) = expected_mtime {
let cur = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(-1);
if cur != expected {
// No readable mtime means the check cannot pass: -1 never matches.
if mtime_secs(&meta).unwrap_or(-1) != expected {
return Err(FsError::Conflict);
}
}
std::fs::write(&full, content).map_err(|e| io_err(e, &full))?;
// Read the new mtime so the client can anchor the next conflict check.
let new_meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
let mtime = new_meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Ok(mtime)
Ok(mtime_secs(&new_meta).unwrap_or(0))
}
fn io_err(e: std::io::Error, p: &Path) -> FsError {
@@ -651,12 +646,12 @@ mod tests {
}
}
// ---------- validate_name ----------
// ---------- validate_component ----------
#[test]
fn validate_name_accepts_simple_names() {
for ok in ["a", "file.txt", "my folder", "Ünïcödé", "with-dash_1.2.3"] {
assert!(validate_name(ok).is_ok(), "{ok:?} should be valid");
assert!(validate_component(ok).is_ok(), "{ok:?} should be valid");
}
}
@@ -665,7 +660,10 @@ mod tests {
for bad in [
"", ".", "..", "a/b", "a\\b", "a\0b", "/abs", "../x", "x/../y", "x/", "/x",
] {
assert!(validate_name(bad).is_err(), "{bad:?} should be invalid");
assert!(
validate_component(bad).is_err(),
"{bad:?} should be invalid"
);
}
}
▾Mserver/tests/api_files.rs
@@ -352,11 +352,12 @@ async fn mkdir_and_rename() {
let env = Env::new().await;
let admin = env.admin().await;
// mkdir (no content-type → mkdir dispatch).
// mkdir names itself with ?action=mkdir.
let mkdir_url = |name: &str| format!("{}?action=mkdir", root_path(name));
let r = admin
.raw(
axum::http::Method::POST,
&root_path("newdir"),
&mkdir_url("newdir"),
&[],
Vec::new(),
)
@@ -367,7 +368,7 @@ async fn mkdir_and_rename() {
let r = admin
.raw(
axum::http::Method::POST,
&root_path("newdir"),
&mkdir_url("newdir"),
&[],
Vec::new(),
)
@@ -375,9 +376,21 @@ async fn mkdir_and_rename() {
assert_eq!(r.status, StatusCode::CONFLICT);
// Empty name → 400 (bare root POST with JSON op is rejected too).
let r = admin
.raw(axum::http::Method::POST, &root_path(""), &[], Vec::new())
.raw(axum::http::Method::POST, &mkdir_url(""), &[], Vec::new())
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// A POST that names no action and carries no known body type is rejected
// instead of silently creating a folder.
let r = admin
.raw(
axum::http::Method::POST,
&root_path("sneaky"),
&[],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
assert!(!env.file("sneaky").exists());
// Rename.
let r = admin
@@ -658,7 +671,7 @@ async fn read_only_root_blocks_writes_but_allows_reads() {
assert_eq!(r.body, b"file a");
// Writes are blocked.
let base = format!("/api/files/{carol_root_id}/x");
let base = format!("/api/files/{carol_root_id}/x?action=mkdir");
assert_eq!(
carol
.raw(axum::http::Method::POST, &base, &[], Vec::new())
@@ -706,7 +719,7 @@ async fn user_cannot_touch_foreign_root() {
assert_eq!(
dave.raw(
axum::http::Method::POST,
"/api/files/1/evil",
"/api/files/1/evil?action=mkdir",
&[],
Vec::new()
)
▾Mserver/tests/api_shares.rs
@@ -215,7 +215,7 @@ async fn read_only_share_blocks_writes_writable_share_allows() {
let r = anon
.raw(
axum::http::Method::POST,
&format!("/api/files/{ro_id}/x?share={ro_tok}"),
&format!("/api/files/{ro_id}/x?share={ro_tok}&action=mkdir"),
&[],
Vec::new(),
)
@@ -228,7 +228,7 @@ async fn read_only_share_blocks_writes_writable_share_allows() {
let r = anon
.raw(
axum::http::Method::POST,
&format!("/api/files/{rw_id}/made-by-share?share={rw_tok}"),
&format!("/api/files/{rw_id}/made-by-share?share={rw_tok}&action=mkdir"),
&[],
Vec::new(),
)
▾Mweb/Cargo.toml
@@ -10,7 +10,7 @@ gloo-timers = { version = "0.3", features = ["futures"] }
js-sys = "0.3"
leptos = { version = "0.8", features = ["csr"] }
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
serde_json = "1"
thiserror = "2"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
▾Mweb/src/api.rs
@@ -11,9 +11,9 @@ use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
use api_types::{
ACTION_CONTENT, ACTION_DOWNLOAD, ACTION_PREVIEW, ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN,
AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare, CreateUser, Credentials, FILES, Mutation,
P_ACTION, P_FORMAT, P_OVERWRITE, P_SHARE, Root, SHARE, SHARES, Settings, UpdateUser,
ACTION_CONTENT, ACTION_DOWNLOAD, ACTION_MKDIR, ACTION_PREVIEW, ADMIN_SETTINGS, ADMIN_USERS,
AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare, CreateUser, Credentials, FILES,
Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_SHARE, Root, SHARE, SHARES, Settings, UpdateUser,
};
pub use api_types::{
AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo, UserInfo,
@@ -157,7 +157,11 @@ pub fn mkdir(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("POST", files_url(root_id, path), None::<()>)
let url = append_query(
&files_url(root_id, path),
&format!("{P_ACTION}={ACTION_MKDIR}"),
);
request("POST", url, None::<()>)
}
pub fn rename_item(
@@ -320,12 +324,7 @@ pub async fn save_content(
Err(e) if e.status() == Some(409) => return Err(ApiError::Conflict),
Err(e) => return Err(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:?}")))?;
let save: SaveResp =
serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))?;
let save: SaveResp = read_json(&resp).await?;
Ok(save.mtime)
}
@@ -621,7 +620,7 @@ 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.set("Content-Type", "application/json");
@@ -670,41 +669,32 @@ async fn do_fetch<T: DeserializeOwned>(
opts: &web_sys::RequestInit,
) -> Result<T, ApiError> {
let resp = fetch_checked(url, opts, "request failed").await?;
read_json(&resp).await
}
let json_promise = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js: JsValue = JsFuture::from(json_promise)
/// Read a response body as text and parse it with serde_json.
///
/// Going through text rather than `Response::json()` keeps one JSON
/// implementation in play. It also keeps the server's 64-bit integers exact:
/// a detour through a JS value would round file sizes through an f64.
async fn read_json<T: DeserializeOwned>(resp: &web_sys::Response) -> Result<T, ApiError> {
let text = response_text(resp)
.await
.map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
.ok_or_else(|| ApiError::Net("could not read the response body".to_string()))?;
serde_json::from_str(&text).map_err(|e| ApiError::Net(format!("response is not JSON: {e}")))
}
serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
async fn response_text(resp: &web_sys::Response) -> Option<String> {
JsFuture::from(resp.text().ok()?).await.ok()?.as_string()
}
/// Parse the server's error JSON (message + optional conflict list).
/// An unparseable body yields the default, and the caller's fallback message.
async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
let Ok(promise) = resp.text() else {
return ErrBody::default();
};
let Ok(js) = JsFuture::from(promise).await else {
return ErrBody::default();
};
let Some(text) = js.as_string() else {
return ErrBody::default();
};
if let Ok(v) = js_sys::JSON::parse(&text)
&& let Ok(body) = serde_wasm_bindgen::from_value::<ErrBody>(v)
{
return body;
}
// Fallback: naive extraction of the "error" string.
const MARKER: &str = "\"error\":\"";
let error = text.find(MARKER).and_then(|start| {
let rest = &text[start + MARKER.len()..];
rest.find('"').map(|end| rest[..end].replace("\\\"", "\""))
});
ErrBody {
error,
skipped: None,
}
response_text(resp)
.await
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
/// Read a form input's value by element id.
@@ -718,13 +708,3 @@ pub fn input_value(id: &str) -> String {
.map(|i| i.value())
.unwrap_or_default()
}
fn serde_json_to_string(v: &impl Serialize) -> Result<String, serde_wasm_bindgen::Error> {
// 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"))
}
▾Mweb/src/views/browser.rs
@@ -574,216 +574,101 @@ fn CtxMenuView(
let me_now = me.get();
let loc_now = loc.get();
let is_rw = me_now
// The root that is actually shown. Resolves the single-root default
// location, where `loc.root_id` is `None`.
let root = me_now.as_ref().and_then(|m2| effective_root(&m2.roots, &loc_now));
let is_rw = root.map(|r| r.mode.is_writable()).unwrap_or(false);
let root_name = root.map(|r| r.name.clone()).unwrap_or_default();
let root_id = root.map(|r| r.id);
let allow_writable_shares = me_now
.as_ref()
.and_then(|m2| effective_root(&m2.roots, &loc_now))
.map(|r| r.mode.is_writable())
.map(|m2| m2.allow_writable_shares)
.unwrap_or(false);
let root_name = me_now
.as_ref()
.and_then(|m2| effective_root(&m2.roots, &loc_now))
.map(|r| r.name.clone())
.unwrap_or_default();
// The id of the root that is actually shown (resolves the single-root
// default location, where `loc.root_id` is `None`).
let eff_root_id = me_now
.as_ref()
.and_then(|m2| effective_root(&m2.roots, &loc_now))
.map(|r| r.id);
let own = || owner.clone();
// (label, action, disabled-hint)
let items: Vec<(String, Option<Callback<()>>, Option<String>)> = match &m.entry {
let items: Vec<MenuItem> = match &m.entry {
Some(e) => {
let e2 = e.clone();
let mut v: Vec<(String, Option<Callback<()>>, Option<String>)> = vec![];
let mut v = vec![];
if e.is_dir {
let l = loc;
let name = e2.name.clone();
v.push((
"Open".into(),
Some(Callback::new(move |_| {
let cur = l.get();
let mut path = cur.path;
path.push(name.clone());
navigate(&Location {
root_id: cur.root_id,
path,
share_token: cur.share_token.clone(),
});
})),
None,
));
}
// Serve the file itself in a new tab. HTML and SVG render
// as real pages there (sandboxed by the server), which is
// how you share a small site or an HTML report.
if !e2.is_dir {
let e_tab = e2.clone();
v.push((
"Open in new tab".into(),
Some(action_open_tab(&e_tab, eff_root_id, loc, owner.clone())),
None,
v.push(MenuItem::new("Open", action_open_dir(e, loc, own())));
} else {
// Serve the file itself in a new tab. HTML and SVG
// render as real pages there (sandboxed by the server),
// which is how you share a small site or an HTML report.
v.push(MenuItem::new(
"Open in new tab",
action_open_tab(e, root_id, loc, own()),
));
}
let e_dl = e2.clone();
let l_dl = loc;
let t_dl = toast;
v.push((
"Download".into(),
Some(action_download(&e_dl, eff_root_id, l_dl, set_dialog, t_dl, owner.clone())),
None,
v.push(MenuItem::new(
"Download",
action_download(e, root_id, loc, set_dialog, toast, own()),
));
// Editing is only offered for files that decode as text.
if e2.kind == FileKind::Text {
let e_ed = e2.clone();
let l_ed = loc;
v.push((
"Edit".into(),
rw_action(
is_rw,
action_edit(&e_ed, eff_root_id, l_ed, set_editor, owner.clone()),
),
Some("Read-only folder".into()),
if e.kind == FileKind::Text {
v.push(MenuItem::rw(
"Edit",
is_rw,
action_edit(e, root_id, loc, set_editor, own()),
));
}
let e_rename = e2.clone();
let l_rename = loc;
v.push((
"Rename".into(),
rw_action(
is_rw,
action_rename(&e_rename, eff_root_id, l_rename, refresh, toast, set_dialog, owner.clone()),
),
Some("Read-only folder".into()),
v.push(MenuItem::rw(
"Rename",
is_rw,
action_rename(e, root_id, loc, refresh, toast, set_dialog, own()),
));
let e_move = e2.clone();
let l_move = loc;
let m_move = me;
v.push((
"Move".into(),
rw_action(
is_rw,
action_move_copy(
Op::Move,
&e_move,
eff_root_id,
m_move,
l_move,
refresh,
toast,
set_dialog,
owner.clone(),
),
),
Some("Read-only folder".into()),
v.push(MenuItem::rw(
"Move",
is_rw,
action_move_copy(Op::Move, e, root_id, me, loc, refresh, toast, set_dialog, own()),
));
let e3 = e2.clone();
let l_copy = loc;
let m_copy = me;
v.push((
"Copy".into(),
Some(action_move_copy(
Op::Copy,
&e3,
eff_root_id,
m_copy,
l_copy,
refresh,
toast,
set_dialog,
owner.clone(),
)),
None,
// Copying only writes at the destination, so a read-only
// source folder is fine.
v.push(MenuItem::new(
"Copy",
action_move_copy(Op::Copy, e, root_id, me, loc, refresh, toast, set_dialog, own()),
));
if loc_now.share_token.is_none() {
let e_sh = e2.clone();
let l_sh = loc;
let allow_wr = me_now
.as_ref()
.map(|m| m.allow_writable_shares)
.unwrap_or(false);
v.push((
"Share".into(),
Some(action_share(
&e_sh,
eff_root_id,
l_sh,
allow_wr,
set_dialog,
owner.clone(),
)),
None,
v.push(MenuItem::new(
"Share",
action_share(e, root_id, loc, allow_writable_shares, set_dialog, own()),
));
}
let e_info = e2.clone();
let l_info = loc;
v.push((
"Info".into(),
Some(action_info(
&e_info,
l_info,
root_name.clone(),
set_dialog,
owner.clone(),
)),
None,
));
let e4 = e2.clone();
let l_del = loc;
v.push((
"Delete".into(),
rw_action(
is_rw,
action_delete(&e4, eff_root_id, l_del, refresh, toast, set_dialog, owner.clone()),
),
Some("Read-only folder".into()),
v.push(MenuItem::new(
"Info",
action_info(e, loc, root_name, set_dialog, own()),
));
v
}
None => {
let mut v: Vec<(String, Option<Callback<()>>, Option<String>)> = vec![];
let l_nf = loc;
v.push((
"New folder".into(),
rw_action(
is_rw,
action_new_folder(eff_root_id, l_nf, refresh, toast, set_dialog, owner.clone()),
),
Some("Read-only folder".into()),
));
let l_up1 = loc;
v.push((
"Upload files".into(),
rw_action(
is_rw,
action_upload(true, false, eff_root_id, l_up1, refresh, toast, set_dialog),
),
Some("Read-only folder".into()),
));
let l_up2 = loc;
v.push((
"Upload folder".into(),
rw_action(
is_rw,
action_upload(true, true, eff_root_id, l_up2, refresh, toast, set_dialog),
),
Some("Read-only folder".into()),
));
v.push((
"Refresh".into(),
Some(Callback::new(move |_| refresh.run(()))),
None,
v.push(MenuItem::rw(
"Delete",
is_rw,
action_delete(e, root_id, loc, refresh, toast, set_dialog, own()),
));
v
}
None => vec![
MenuItem::rw(
"New folder",
is_rw,
action_new_folder(root_id, loc, refresh, toast, set_dialog, own()),
),
MenuItem::rw(
"Upload files",
is_rw,
action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
),
MenuItem::rw(
"Upload folder",
is_rw,
action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
),
MenuItem::new("Refresh", Callback::new(move |_| refresh.run(()))),
],
};
view! {
<div class="ctx-menu" style=format!("left:{x}px;top:{y}px")>
{items.into_iter().map(|(label, action, hint)| {
view! {
<CtxItem label=label action=action hint=hint/>
}
{items.into_iter().map(|item| {
view! { <CtxItem item=item/> }
}).collect::<Vec<_>>()}
</div>
}
@@ -793,25 +678,46 @@ fn CtxMenuView(
}
}
/// `Some(cb)` when the current folder is writable, else `None` (disabled item).
fn rw_action(is_rw: bool, cb: Callback<()>) -> Option<Callback<()>> {
if is_rw { Some(cb) } else { None }
/// One row of the context menu. `action` is `None` for a disabled row, and
/// `hint` then says why it is disabled.
struct MenuItem {
label: &'static str,
action: Option<Callback<()>>,
hint: Option<&'static str>,
}
impl MenuItem {
fn new(label: &'static str, action: Callback<()>) -> Self {
Self {
label,
action: Some(action),
hint: None,
}
}
/// An item that only works in a writable folder.
fn rw(label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
Self {
label,
action: is_rw.then_some(action),
hint: Some("Read-only folder"),
}
}
}
#[component]
fn CtxItem(label: String, action: Option<Callback<()>>, hint: Option<String>) -> impl IntoView {
fn CtxItem(item: MenuItem) -> impl IntoView {
let MenuItem {
label,
action,
hint,
} = item;
let enabled = action.is_some();
let hint = hint.unwrap_or_else(|| "Coming soon".to_string());
let hint = hint.unwrap_or_default();
view! {
<div
class=move || {
if enabled {
"ctx-item".to_string()
} else {
"ctx-item disabled".to_string()
}
}
title=move || if enabled { String::new() } else { hint.clone() }
class=move || if enabled { "ctx-item" } else { "ctx-item disabled" }
title=move || if enabled { "" } else { hint }
on:click=move |_| {
if let Some(a) = &action {
a.run(());
@@ -839,6 +745,63 @@ 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.
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,
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(toast, e.to_string());
return;
};
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(toast, e.to_string()),
}
});
}),
}));
});
}
fn action_new_folder(
root_id: Option<i64>,
loc: ReadSignal<Location>,
@@ -906,40 +869,24 @@ fn action_rename(
return;
}
set_dialog.set(None);
let f1 = full.clone();
let f2 = full.clone();
spawn_local(async move {
match api::rename_item(root_id, &f1, new_name.clone(), false).await {
Ok(_) => {
show(toast, "Renamed");
refresh.run(());
}
Err(e) => {
if is_conflict(&e) {
set_dialog.set(Some(Dialog::Conflict {
title: "Name already in use".into(),
files: vec![new_name.clone()],
on_submit: Callback::new(move |_| {
let f3 = f2.clone();
let n2 = new_name.clone();
spawn_local(async move {
match api::rename_item(root_id, &f3, n2, true).await
{
Ok(_) => {
show(toast, "Renamed");
refresh.run(());
}
Err(e2) => show(toast, e2.to_string()),
}
});
}),
}));
} else {
show(toast, e.to_string());
}
}
}
});
let full = full.clone();
let target = new_name.clone();
run_with_overwrite_retry(
move |overwrite| {
let (full, new_name) = (full.clone(), new_name.clone());
Box::pin(async move {
api::rename_item(root_id, &full, new_name, overwrite)
.await
.map(|_| ())
})
},
"Name already in use",
vec![target],
"Renamed",
refresh,
toast,
set_dialog,
);
})
}),
}));
@@ -1009,8 +956,7 @@ fn action_upload(
let dir1 = dir.clone();
api::pick_files(multiple, directory, move |files| {
let n = files.len();
let retry = files.clone();
let dir2 = dir1.clone();
let dir = dir1.clone();
show(
toast,
format!(
@@ -1018,38 +964,19 @@ fn action_upload(
if n == 1 { "" } else { "s" }
),
);
spawn_local(async move {
match api::upload(root_id, &dir2, false, files).await {
Ok(_) => {
show(toast, "Upload complete");
refresh.run(());
}
Err(e) => {
if let Some(skipped) = e.skipped() {
let skipped = skipped.to_vec();
set_dialog.set(Some(Dialog::Conflict {
title: "Some files already exist".into(),
files: skipped,
on_submit: Callback::new(move |_| {
let rf = retry.clone();
let dir3 = dir2.clone();
spawn_local(async move {
match api::upload(root_id, &dir3, true, rf).await {
Ok(_) => {
show(toast, "Upload complete");
refresh.run(());
}
Err(e2) => show(toast, e2.to_string()),
}
});
}),
}));
} else {
show(toast, e.to_string());
}
}
}
});
run_with_overwrite_retry(
move |overwrite| {
let (dir, files) = (dir.clone(), files.clone());
Box::pin(async move { api::upload(root_id, &dir, overwrite, files).await })
},
"Some files already exist",
// Unused: an upload conflict always names the skipped files.
Vec::new(),
"Upload complete",
refresh,
toast,
set_dialog,
);
});
})
}
@@ -1088,63 +1015,28 @@ fn action_move_copy(
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,
)
.await
};
match res {
Ok(_) => {
show(
toast,
if op == Op::Move {
"Moved"
} else {
"Copied"
},
);
refresh.run(());
}
Err(e2) => show(toast, e2.to_string()),
}
});
}),
}));
let full = full3.clone();
run_with_overwrite_retry(
move |overwrite| {
let (full, dst_dir) = (full.clone(), dst_dir.clone());
Box::pin(async move {
let res = if op == Op::Move {
api::move_item(root_id, &full, dst_root, &dst_dir, overwrite)
.await
} else {
show(toast, e.to_string());
}
}
}
});
api::copy_item(root_id, &full, dst_root, &dst_dir, overwrite)
.await
};
res.map(|_| ())
})
},
"Name already in use",
vec![name3.clone()],
if op == Op::Move { "Moved" } else { "Copied" },
refresh,
toast,
set_dialog,
);
})
}),
}));
@@ -1181,6 +1073,23 @@ fn action_download(
})
}
/// Descend into a folder.
fn action_open_dir(entry: &Entry, loc: ReadSignal<Location>, owner: Owner) -> Callback<()> {
let name = entry.name.clone();
owner.with(|| {
Callback::new(move |_| {
let cur = loc.get();
let mut path = cur.path;
path.push(name.clone());
navigate(&Location {
root_id: cur.root_id,
path,
share_token: cur.share_token,
});
})
})
}
/// Open the file itself in a new tab, via the `?action=preview` endpoint —
/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
fn action_open_tab(