upload panel, batching, drag and drop, long-press menu, default root
Uploads run as background jobs in a panel: pause, resume, abort, speed graph on a fixed interval, file counter. The panel appears at once while the picked or dropped files are still being walked. Upload pre-check (?action=exists, chunked by byte budget) asks once for files that already exist. Files that appear during the transfer come back from the server as a 409 skipped list and prompt inline; "apply to all" extends the dialog answer to them. Small files are batched into one multipart request (1 MiB / 200 files). Multipart names are escaped and decoded, a folder in the target path is reported per file. Drag and drop upload with a drop hint. Dropped folders are walked through the entries API. The DataTransfer is read inside the drop handler, and entries are converted without an instanceof check because Chromium has no global FileSystemEntry. Background dialogs (upload conflict, move/copy conflict) wait until the dialog slot is free instead of replacing an open dialog. Touch long press opens the context menu on rows and on empty space. Admin setting: default root profile for new sessions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mapi-types/src/lib.rs
@@ -60,6 +60,9 @@ pub const ACTION_MKDIR: &str = "mkdir";
/// `POST {FILES}/...?action=create-file` — create an empty file. Explicit
/// like `mkdir`, for the same reason.
pub const ACTION_CREATE_FILE: &str = "create-file";
/// `POST {FILES}/...?action=exists` with an [`ExistsReq`] body — read-only
/// pre-check for an upload: which of the given targets already exist.
pub const ACTION_EXISTS: &str = "exists";
/// `?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.
@@ -317,6 +320,11 @@ pub struct UserInfo {
/// Preferred UI language tag ("en", "de", "fr"); None = follow the
/// browser.
pub language: Option<String>,
/// Profile setting: the root the UI opens on page load and on the home
/// link. Always one of `Me::roots` (the server drops a stale id), or
/// None for the root picker.
#[serde(default)]
pub default_root_id: Option<i64>,
}
#[derive(Serialize, Deserialize, Clone)]
@@ -408,6 +416,27 @@ pub struct DeleteResp {
pub is_dir: bool,
}
/// `POST ...?action=exists` body: upload targets relative to the request
/// directory (may contain subfolders, like upload part names).
#[derive(Serialize, Deserialize)]
pub struct ExistsReq {
pub paths: Vec<String>,
}
/// One existing upload target.
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct Existing {
pub path: String,
pub is_dir: bool,
}
/// `POST ...?action=exists` response: the subset of the requested paths
/// that exist, in request order.
#[derive(Serialize, Deserialize)]
pub struct ExistsResp {
pub existing: Vec<Existing>,
}
/// Upload success: how many files were written.
#[derive(Serialize, Deserialize)]
pub struct UploadResp {
@@ -494,6 +523,7 @@ mod tests {
single_click_open: false,
thumbnails: true,
language: None,
default_root_id: None,
}),
roots: vec![RootInfo {
id: 1,
@@ -503,6 +533,7 @@ mod tests {
}],
allow_writable_shares: false,
thumbnails_available: true,
public_url: None,
};
let s = serde_json::to_string(&me).unwrap();
let back: Me = serde_json::from_str(&s).unwrap();
Mserver/src/api/auth.rs
@@ -60,6 +60,11 @@ async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me
single_click_open: user.single_click,
thumbnails: user.thumbnails,
language: user.language.clone(),
// A removed root leaves a stale id behind; the client never
// sees it.
default_root_id: user
.default_root_id
.filter(|id| roots.iter().any(|r| r.id == *id)),
}),
roots,
allow_writable_shares: state.db.allow_writable_shares().await?,
@@ -78,18 +83,22 @@ pub(crate) struct ProfilePatch {
#[serde(default)]
pub single_click_open: Option<bool>,
pub thumbnails: Option<bool>,
#[serde(default, deserialize_with = "language_field")]
#[serde(default, deserialize_with = "patch_field")]
pub language: Option<Option<String>>,
/// `null` clears the default root (back to the root picker).
#[serde(default, deserialize_with = "patch_field")]
pub default_root_id: Option<Option<i64>>,
}
/// Deserializes `ProfilePatch::language` into the three-state patch value:
/// Deserializes a nullable patch field into the three-state value:
/// `"de"` → `Some(Some("de"))`, `null` → `Some(None)` (a missing field
/// never calls this and stays `None` via `#[serde(default)]`). The inner
/// `Option<String>` already maps `null` → `None` and `"de"` → `Some`, so
/// only the outer wrap is custom.
fn language_field<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
/// `Option<T>` already maps `null` → `None` and a value → `Some`, so only
/// the outer wrap is custom.
fn patch_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
serde::Deserialize::deserialize(deserializer).map(Some)
}
@@ -131,6 +140,19 @@ pub async fn update_profile(
state.db.set_user_language(user.id, lang.as_deref()).await?;
user.language = lang;
}
if let Some(root_id) = body.default_root_id {
if let Some(id) = root_id
&& !roots.iter().any(|r| r.id == id)
{
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"not one of your folders",
"err_invalid_default_root",
));
}
state.db.set_user_default_root(user.id, root_id).await?;
user.default_root_id = root_id;
}
Ok(Json(me_for(&state, &user, roots).await?))
}
Mserver/src/api/files.rs
@@ -6,11 +6,12 @@
//! - `GET /api/files/{root_id}` and `/api/files/{root_id}/{*path}` — list
//! - `DELETE /api/files/{root_id}/{*path}` — delete a file or folder
//! - `POST /api/files/{root_id}/{*path}` — create a folder (no body)
//! - `POST /api/files/{root_id}/{*path}` (JSON body) — rename / move / copy
//! - `POST /api/files/{root_id}/{*path}` (JSON body) — rename / move / copy,
//! or `?action=exists` — which upload targets already exist
//! - `POST /api/files/{root_id}/{*path}` (multipart) — upload into the dir
use std::io::{self, Read};
use std::path::{Component, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use axum::Json;
@@ -30,7 +31,8 @@ use crate::db::{RootRow, ShareRow};
use crate::error::{ApiError, AppState};
use crate::fs::{self, FsError};
use api_types::{
DeleteResp, FilesResp, Mutation, OkResp, Op, P_ACTION, P_OVERWRITE, SaveResp, UploadResp,
DeleteResp, Existing, ExistsReq, ExistsResp, FilesResp, Mutation, OkResp, Op, P_ACTION,
P_OVERWRITE, SaveResp, UploadResp,
};
/// Upper bound for the in-memory text endpoint (preview, later editor).
@@ -754,6 +756,7 @@ async fn dispatch_inner(
return upload(state, auth, root_id, req_rel, req).await;
}
if ct.starts_with("application/json") {
let is_exists = action_param(req.uri()).as_deref() == Some(api_types::ACTION_EXISTS);
let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
.await
.map_err(|_| {
@@ -763,15 +766,20 @@ async fn dispatch_inner(
"err_bad_body",
)
})?;
let body: Mutation = axum::Json::from_bytes(&bytes)
.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid request body",
"err_bad_body",
)
})?
.0;
let bad_body = |_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid request body",
"err_bad_body",
)
};
if is_exists {
let body: ExistsReq = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0;
return Ok(exists(state, auth, root_id, req_rel, body)
.await?
.into_response());
}
let body: Mutation = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0;
return Ok(mutation(state, auth, root_id, req_rel, body)
.await?
.into_response());
@@ -959,19 +967,7 @@ async fn upload(
req_rel: String,
req: axum::http::Request<axum::body::Body>,
) -> Result<Response, ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
// `base` is the upload directory; `root_abs` the user's root, which is the
// containment boundary (a symlink may legitimately point elsewhere inside it).
let (root_abs, base) = {
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
blocking(move || {
Ok::<_, FsError>((
fs::resolve_root(&server_root, &root_rel)?,
fs::resolve_dir(&server_root, &root_rel, &rel)?,
))
})
.await?
};
let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?;
let boundary = req
.headers()
.get(header::CONTENT_TYPE)
@@ -1002,7 +998,7 @@ async fn upload(
.name()
.filter(|n| !n.is_empty())
.or_else(|| field.file_name())
.map(str::to_string)
.map(decode_cd)
.ok_or_else(|| {
ApiError::localized(
StatusCode::BAD_REQUEST,
@@ -1013,65 +1009,14 @@ async fn upload(
validate_rel_path(&part_name)?;
let target = base.join(&part_name);
let parent = target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid part name",
"err_bad_part_name",
)
})?;
// Create the parent, then canonicalize it and require it to still be
// inside the upload directory. `validate_rel_path` blocks `..`, but a
// symlinked directory on disk would otherwise carry the write outside.
let (p, b) = (parent.to_path_buf(), root_abs.clone());
let file_name = target.file_name().map(|n| n.to_owned());
let (parent, target_state) = tokio::task::spawn_blocking(move || {
let escape = || io::Error::other("upload parent escapes the root");
// Check the nearest existing ancestor *before* creating anything,
// so no directory is ever created outside the root either.
let mut existing = p.as_path();
while !existing.exists() {
existing = existing.parent().ok_or_else(escape)?;
}
if !fs::is_within_or_eq(&b, &existing.canonicalize()?) {
return Err(escape());
}
if !p.is_dir() {
std::fs::create_dir_all(&p)?;
}
let canon = p.canonicalize()?;
if !fs::is_within_or_eq(&b, &canon) {
return Err(escape());
}
// Whether the target already exists, and as what: two stats that
// belong on this thread, not on an async worker.
let state = file_name
.map(|n| canon.join(n))
.map(|t| (t.exists(), t.is_dir()));
Ok::<_, io::Error>((canon, state))
})
.await
.map_err(|_| io::Error::other("join"))?
.map_err(|e| {
tracing::warn!(error = %e, "upload parent rejected");
ApiError::localized(
StatusCode::FORBIDDEN,
"invalid file path in upload",
"err_bad_upload_path",
)
})?;
let (Some(file_name), Some((exists, is_dir))) = (target.file_name(), target_state) else {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid part name",
"err_bad_part_name",
));
};
let target = parent.join(file_name);
let (r, b, rel) = (root_abs.clone(), base.clone(), part_name.clone());
let target = tokio::task::spawn_blocking(move || upload_target(&r, &b, &rel, true))
.await
.map_err(|_| io::Error::other("join"))?
.map_err(target_error)?
.expect("create_parent resolves every parent");
let (exists, is_dir) = (target.exists, target.is_dir);
let target = target.path;
if exists && !overwrite {
// Drain this part and report it as a conflict at the end.
@@ -1085,26 +1030,29 @@ async fn upload(
skipped.push(part_name);
continue;
}
if exists {
if is_dir {
return Err(ApiError::localized(
StatusCode::CONFLICT,
"a folder with this name already exists",
"err_folder_exists",
));
}
tokio::fs::remove_file(&target).await.map_err(|_| {
if exists && is_dir {
// A folder can never be replaced by a file. Report it like a
// conflict so the client fails this one part, not the request:
// a rejected request makes it retry every other file alone.
while let Some(_chunk) = field.chunk().await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
StatusCode::BAD_REQUEST,
"invalid upload data",
"err_bad_upload",
)
})?;
})? {}
skipped.push(part_name);
continue;
}
// Stream to a temp file in the same directory, then rename into place.
// Stream to a temp file in the same directory, then publish.
let suffix = crate::auth::random_token();
let tmp = Scratch(parent.join(format!(".upload-{suffix}")));
let tmp = Scratch(
target
.parent()
.expect("has parent")
.join(format!(".upload-{suffix}")),
);
let tmp_file = tokio::fs::File::create(&tmp.0).await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -1143,18 +1091,27 @@ async fn upload(
let tmp2 = tmp.0.clone();
let target2 = target.clone();
// Flatten both errors: the outer `Err` is a panicking or shut-down task,
// the inner one is `rename` refusing. Either way nothing was published.
let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2))
// the inner one is `publish` refusing. Either way nothing was published.
let published = tokio::task::spawn_blocking(move || publish(&tmp2, &target2, overwrite))
.await
.map_err(io::Error::other)
.and_then(|r| r);
if let Err(e) = renamed {
tracing::warn!(error = %e, path = %target.display(), "rename failed during upload");
return Err(ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
));
match published {
Ok(Published::Written) => {}
// The target appeared while the body streamed in. The drop
// guard removes the scratch file.
Ok(Published::Exists) => {
skipped.push(part_name);
continue;
}
Err(e) => {
tracing::warn!(error = %e, path = %target.display(), "publish failed during upload");
return Err(ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
));
}
}
tmp.disarm();
uploaded += 1;
@@ -1178,11 +1135,206 @@ async fn upload(
Ok(Json(UploadResp { uploaded }).into_response())
}
/// The rw root and the upload directory. `root_abs` is the containment
/// boundary (a symlink may legitimately point elsewhere inside it), `base`
/// the directory the request names.
async fn upload_base(
state: &AppState,
auth: &AuthUser,
root_id: i64,
req_rel: String,
) -> Result<(PathBuf, PathBuf), ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
let (server_root, root_rel) = (state.root.clone(), root.path.clone());
blocking(move || {
Ok::<_, FsError>((
fs::resolve_root(&server_root, &root_rel)?,
fs::resolve_dir(&server_root, &root_rel, &req_rel)?,
))
})
.await
}
/// One upload target on disk. `path` has a canonical parent that is inside
/// the root.
struct Target {
path: PathBuf,
exists: bool,
is_dir: bool,
}
/// Resolve `rel` under `base` for an upload. The nearest existing ancestor
/// and the final parent are both checked against `root_abs`, so nothing is
/// created or written outside the root even through a symlinked directory.
/// With `create_parent` missing directories are created. Without it a
/// missing parent returns `None`: the target cannot exist.
///
/// `exists` uses `symlink_metadata`, so a dangling symlink counts as
/// existing and is not silently replaced.
fn upload_target(
root_abs: &Path,
base: &Path,
rel: &str,
create_parent: bool,
) -> io::Result<Option<Target>> {
let escape = || io::Error::other("upload parent escapes the root");
let full = base.join(rel);
let (Some(parent), Some(name)) = (
full.parent().filter(|p| !p.as_os_str().is_empty()),
full.file_name(),
) else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"invalid part name",
));
};
let mut existing = parent;
while !existing.exists() {
existing = existing.parent().ok_or_else(escape)?;
}
if !fs::is_within_or_eq(root_abs, &existing.canonicalize()?) {
return Err(escape());
}
if !parent.is_dir() {
if !create_parent {
return Ok(None);
}
std::fs::create_dir_all(parent)?;
}
let canon = parent.canonicalize()?;
if !fs::is_within_or_eq(root_abs, &canon) {
return Err(escape());
}
let path = canon.join(name);
Ok(Some(Target {
exists: std::fs::symlink_metadata(&path).is_ok(),
is_dir: std::fs::metadata(&path).is_ok_and(|m| m.is_dir()),
path,
}))
}
/// Map an [`upload_target`] error to the API error: a malformed name is a
/// 400, everything else (escape, io) a 403 as before.
fn target_error(e: io::Error) -> ApiError {
if e.kind() == io::ErrorKind::InvalidInput {
return ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid part name",
"err_bad_part_name",
);
}
tracing::warn!(error = %e, "upload parent rejected");
ApiError::localized(
StatusCode::FORBIDDEN,
"invalid file path in upload",
"err_bad_upload_path",
)
}
/// `POST /api/files/{root_id}/{*path}?action=exists` — which upload targets
/// already exist. Read-only: no directory is created. The client asks this
/// before uploading so the overwrite question comes before the transfer.
async fn exists(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
body: ExistsReq,
) -> Result<Json<ExistsResp>, ApiError> {
if body.paths.len() > 10_000 {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"too many paths",
"err_too_many_paths",
));
}
for p in &body.paths {
validate_rel_path(p)?;
}
let (root_abs, base) = upload_base(&state, &auth, root_id, req_rel).await?;
let existing = tokio::task::spawn_blocking(move || {
let mut out = Vec::new();
for path in body.paths {
if let Some(t) = upload_target(&root_abs, &base, &path, false)?
&& t.exists
{
out.push(Existing {
path,
is_dir: t.is_dir,
});
}
}
Ok::<_, io::Error>(out)
})
.await
.map_err(|_| io::Error::other("join"))?
.map_err(target_error)?;
Ok(Json(ExistsResp { existing }))
}
/// Outcome of [`publish`].
enum Published {
Written,
/// The target exists and `overwrite` was off. Nothing was replaced.
Exists,
}
/// Move the finished scratch file to `target`. With `overwrite`, `rename`
/// replaces whatever is there. Without it the target must not exist at the
/// moment of publishing: `hard_link` fails with `AlreadyExists` atomically,
/// which closes the window between the pre-upload stat and the publish.
/// On success `tmp` is gone in both cases.
fn publish(tmp: &Path, target: &Path, overwrite: bool) -> io::Result<Published> {
if overwrite {
std::fs::rename(tmp, target)?;
return Ok(Published::Written);
}
match std::fs::hard_link(tmp, target) {
Ok(()) => {
std::fs::remove_file(tmp)?;
Ok(Published::Written)
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(Published::Exists),
Err(e) if link_unsupported(&e) => {
tracing::warn!(error = %e, "hard links unsupported here, falling back to stat + rename");
// ponytail: stat-then-rename leaves a microsecond window in which
// a file created by someone else is replaced. Closing it needs
// renameat2(RENAME_NOREPLACE) through libc.
if std::fs::symlink_metadata(target).is_ok() {
return Ok(Published::Exists);
}
std::fs::rename(tmp, target)?;
Ok(Published::Written)
}
Err(e) => Err(e),
}
}
/// The filesystem refuses hard links: EPERM (some network mounts, restricted
/// namespaces), ENOTSUP, or EXDEV. Only EXDEV needs its raw code; the other
/// two map to an `ErrorKind`.
fn link_unsupported(e: &io::Error) -> bool {
matches!(
e.kind(),
io::ErrorKind::PermissionDenied | io::ErrorKind::Unsupported
) || e.raw_os_error() == Some(18)
}
/// Undo the client's `Content-Disposition` escaping (WHATWG form-data): the
/// three characters that cannot appear raw in a quoted header value. `multer`
/// does not do this itself.
fn decode_cd(s: &str) -> String {
s.replace("%22", "\"")
.replace("%0D", "\r")
.replace("%0A", "\n")
}
/// The `.upload-<token>` scratch file of one in-flight upload part. Dropping it
/// removes the file, which covers the paths no `return` can see, above all the
/// request future being dropped when the client closes the connection. A leaked
/// scratch file is never named again and shows up in listings, which include
/// hidden entries on purpose. [`Scratch::disarm`] after `rename` keeps the file.
/// hidden entries on purpose. [`Scratch::disarm`] after a publish skips the
/// unlink of a path that is now the uploaded file.
struct Scratch(PathBuf);
impl Scratch {
@@ -1304,6 +1456,43 @@ mod tests {
use api_types::{ACTION_DOWNLOAD, P_FORMAT};
use axum::http::Uri;
/// The publish step must never replace a file that appeared after the
/// pre-upload stat unless `overwrite` is on.
#[test]
fn publish_refuses_an_existing_target_without_overwrite() {
let dir = tempfile::tempdir().unwrap();
let tmp = dir.path().join(".upload-1");
let target = dir.path().join("a.txt");
std::fs::write(&tmp, b"new").unwrap();
assert!(matches!(
publish(&tmp, &target, false).unwrap(),
Published::Written
));
assert_eq!(std::fs::read(&target).unwrap(), b"new");
assert!(!tmp.exists(), "scratch file must be gone after publish");
// The target exists now: no overwrite → untouched.
std::fs::write(&tmp, b"racer").unwrap();
assert!(matches!(
publish(&tmp, &target, false).unwrap(),
Published::Exists
));
assert_eq!(std::fs::read(&target).unwrap(), b"new");
assert!(
tmp.exists(),
"the caller's drop guard removes the scratch file"
);
// With overwrite the target is replaced.
assert!(matches!(
publish(&tmp, &target, true).unwrap(),
Published::Written
));
assert_eq!(std::fs::read(&target).unwrap(), b"racer");
assert!(!tmp.exists());
}
/// A rename of `P_ACTION` or `P_FORMAT` without the matching field
/// rename would silently stop the server from reading the parameter the
/// client sends. This builds the query string from the constants and
Mserver/src/db.rs
@@ -5,7 +5,7 @@ pub use api_types::Mode;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA_VERSION: i64 = 8;
const SCHEMA_VERSION: i64 = 9;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
@@ -42,6 +42,9 @@ pub struct User {
/// Preferred UI language tag ("en", "de", "fr"); None = follow the
/// browser.
pub language: Option<String>,
/// Profile setting: the root the UI opens by default. May point at a
/// root the user no longer has; the API filters that out.
pub default_root_id: Option<i64>,
}
#[derive(Debug, Clone)]
@@ -202,6 +205,11 @@ impl Db {
"ALTER TABLE users ADD COLUMN thumbnails INTEGER NOT NULL DEFAULT 1",
)?;
}
if version < 9 {
// Per-user default root. No foreign key on purpose: removing a
// root must not fail because of this column.
conn.execute_batch("ALTER TABLE users ADD COLUMN default_root_id INTEGER")?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -248,6 +256,7 @@ impl Db {
single_click: false,
thumbnails: true,
language: None,
default_root_id: None,
}))
}
@@ -255,12 +264,22 @@ impl Db {
// The guard is scoped to the query alone. Argon2 below is slow by
// design; holding the single connection lock across it would make one
// login serialize every other database access.
type UserRow = (i64, String, bool, String, bool, bool, bool, Option<String>);
type UserRow = (
i64,
String,
bool,
String,
bool,
bool,
bool,
Option<String>,
Option<i64>,
);
let row: Option<UserRow> = {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, pass_hash, active != 0, single_click != 0,
thumbnails != 0, language
thumbnails != 0, language, default_root_id
FROM users WHERE name = ?1",
[name],
|r| {
@@ -273,6 +292,7 @@ impl Db {
r.get(5)?,
r.get(6)?,
r.get(7)?,
r.get(8)?,
))
},
)
@@ -281,18 +301,33 @@ impl Db {
// An unknown or disabled name still pays for one Argon2 verify, so the
// response time does not reveal which names exist.
let (row, hash) = match row {
Some((id, name, is_admin, hash, active, single_click, thumbnails, language))
if active =>
{
(
Some((id, name, is_admin, single_click, thumbnails, language)),
hash,
)
}
Some((
id,
name,
is_admin,
hash,
active,
single_click,
thumbnails,
language,
default_root_id,
)) if active => (
Some((
id,
name,
is_admin,
single_click,
thumbnails,
language,
default_root_id,
)),
hash,
),
_ => (None, DUMMY_HASH.clone()),
};
let ok = crate::auth::verify_password_async(password, &hash).await;
let Some((id, name, is_admin, single_click, thumbnails, language)) = row else {
let Some((id, name, is_admin, single_click, thumbnails, language, default_root_id)) = row
else {
return Ok(None);
};
Ok(ok.then_some(User {
@@ -303,6 +338,7 @@ impl Db {
single_click,
thumbnails,
language,
default_root_id,
}))
}
@@ -333,7 +369,7 @@ impl Db {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
u.thumbnails != 0, u.language,
u.thumbnails != 0, u.language, u.default_root_id,
r.id, r.path, r.mode
FROM sessions s
JOIN users u ON u.id = s.user_id
@@ -350,11 +386,11 @@ impl Db {
if user.is_none() {
user = Some(map_user(r)?);
}
if let Some(id) = r.get::<_, Option<i64>>(7)? {
if let Some(id) = r.get::<_, Option<i64>>(8)? {
roots.push(RootRow {
id,
path: r.get(8)?,
mode: r.get::<_, SqlMode>(9)?.0,
path: r.get(9)?,
mode: r.get::<_, SqlMode>(10)?.0,
});
}
}
@@ -386,7 +422,7 @@ impl Db {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
u.thumbnails != 0, u.language,
u.thumbnails != 0, u.language, u.default_root_id,
r.id, r.path, r.mode
FROM users u
LEFT JOIN user_roots r ON r.user_id = u.id
@@ -400,11 +436,11 @@ impl Db {
if out.last().is_none_or(|(u, _)| u.id != uid) {
out.push((map_user(r)?, Vec::new()));
}
if let Some(id) = r.get::<_, Option<i64>>(7)? {
if let Some(id) = r.get::<_, Option<i64>>(8)? {
out.last_mut().expect("pushed above").1.push(RootRow {
id,
path: r.get(8)?,
mode: r.get::<_, SqlMode>(9)?.0,
path: r.get(9)?,
mode: r.get::<_, SqlMode>(10)?.0,
});
}
}
@@ -415,7 +451,7 @@ impl Db {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, thumbnails != 0,
language
language, default_root_id
FROM users WHERE id = ?1",
[id],
map_user,
@@ -427,7 +463,7 @@ impl Db {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, thumbnails != 0,
language
language, default_root_id
FROM users WHERE name = ?1",
[name],
map_user,
@@ -475,6 +511,7 @@ impl Db {
single_click: false,
thumbnails: true,
language: None,
default_root_id: None,
})
}
@@ -496,6 +533,15 @@ impl Db {
Ok(())
}
pub async fn set_user_default_root(&self, id: i64, root_id: Option<i64>) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET default_root_id = ?1 WHERE id = ?2",
params![root_id, id],
)?;
Ok(())
}
pub async fn set_user_language(&self, id: i64, language: Option<&str>) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
@@ -791,6 +837,7 @@ fn map_user(r: &rusqlite::Row) -> DbResult<User> {
single_click: r.get(4)?,
thumbnails: r.get(5)?,
language: r.get(6)?,
default_root_id: r.get(7)?,
})
}
Mserver/tests/api_auth.rs
@@ -240,3 +240,61 @@ async fn public_url_reaches_client() {
"https://files.example.com"
);
}
#[tokio::test]
async fn default_root_is_validated_and_dropped_with_its_root() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(
&admin,
"bob",
"bobpass123",
&[("docs", "rw"), ("src", "ro")],
)
.await;
let bob = login(&env, "bob", "bobpass123").await;
let me = bob.get("/api/auth/me").await.json();
assert_eq!(me["user"]["default_root_id"], json!(null));
let src_id = me["roots"][1]["id"].as_i64().unwrap();
let admin_root_id = admin.get("/api/auth/me").await.json()["roots"][0]["id"]
.as_i64()
.unwrap();
// A root of another user → 400.
let r = bob
.put_json("/api/auth/me", &json!({ "default_root_id": admin_root_id }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
// One of bob's own roots is echoed back.
let r = bob
.put_json("/api/auth/me", &json!({ "default_root_id": src_id }))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.json()["user"]["default_root_id"], json!(src_id));
assert_eq!(
bob.get("/api/auth/me").await.json()["user"]["default_root_id"],
json!(src_id)
);
// The admin takes that root away: /me no longer names it.
let id = user_id(&admin, "bob").await;
let r = admin
.put_json(
&format!("/api/admin/users/{id}"),
&json!({ "roots": [{ "path": "docs", "mode": "rw" }] }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(
bob.get("/api/auth/me").await.json()["user"]["default_root_id"],
json!(null)
);
// null clears it explicitly.
let r = bob
.put_json("/api/auth/me", &json!({ "default_root_id": null }))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["user"]["default_root_id"], json!(null));
}
Mserver/tests/api_files.rs
@@ -705,11 +705,30 @@ async fn upload_creates_files_and_folders() {
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"v3");
// A part name that is an existing directory → 409.
// A part name that is an existing directory → 409, and it is named in
// `skipped` so the client can fail just that file. Also with
// overwrite=true: a folder is never replaced by a file.
for query in ["", "overwrite=true"] {
let r = admin
.post_multipart(
&root_path(""),
&[("new", b"dir?"), ("beside.txt", b"ok")],
query,
)
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
assert_eq!(r.json()["skipped"], json!(["new"]));
assert!(env.file("new").is_dir());
std::fs::remove_file(env.file("beside.txt")).unwrap();
}
// A quote in the part name: the client percent-escapes it, the server
// decodes it back (multer only unescapes backslashes).
let r = admin
.post_multipart(&root_path(""), &[("new", b"dir?")], "")
.post_multipart(&root_path(""), &[("qu%22ote.txt", b"q")], "")
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("qu\"ote.txt")).unwrap(), b"q");
// Path traversal in a part name → 400.
let r = admin
@@ -766,6 +785,165 @@ async fn upload_does_not_follow_symlinked_directories_out_of_the_root() {
assert_eq!(std::fs::read(env.file("src/ok.txt")).unwrap(), b"fine");
}
#[tokio::test]
async fn exists_check_reports_targets_without_creating_anything() {
let env = Env::new().await;
let admin = env.admin().await;
let url = format!("{}?action=exists", root_path(""));
let r = admin
.post_json(
&url,
&json!({ "paths": [
"docs/a.txt",
"docs",
"missing.txt",
"nowhere/deep/file.txt",
] }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
r.json()["existing"],
json!([
{ "path": "docs/a.txt", "is_dir": false },
{ "path": "docs", "is_dir": true },
])
);
assert!(
!env.file("nowhere").exists(),
"the check must not create parent folders"
);
// Traversal → 400.
let r = admin
.post_json(&url, &json!({ "paths": ["../evil.txt"] }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Read-only roots cannot be uploaded to, so they cannot be checked either.
create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await;
let carol = login(&env, "carol", "carolpass1").await;
let carol_root = carol.get("/api/auth/me").await.json()["roots"][0]["id"]
.as_i64()
.unwrap();
let r = carol
.post_json(
&format!("/api/files/{carol_root}?action=exists"),
&json!({ "paths": ["a.txt"] }),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
#[cfg(unix)]
#[tokio::test]
async fn exists_check_does_not_follow_symlinked_directories_out_of_the_root() {
let env = Env::new().await;
let admin = env.admin().await;
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.txt"), b"s").unwrap();
std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap();
for part in ["link/secret.txt", "link/deeper/x.txt"] {
let r = admin
.post_json(
&format!("{}?action=exists", root_path("docs")),
&json!({ "paths": [part] }),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{part}: {}", r.text());
}
assert!(!outside.path().join("deeper").exists());
}
/// A complete multipart body for one part, streamed in two halves. `between`
/// runs after the first half reached the server and before the second is
/// sent, so the test can change the disk while the upload is in flight.
async fn upload_in_two_halves(
env: &Env,
admin: &Client,
name: &str,
between: impl FnOnce() + Send + 'static,
) -> (StatusCode, serde_json::Value) {
let mut body: Vec<u8> = Vec::new();
body.extend_from_slice(
format!("--B\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
);
body.extend_from_slice(&vec![b'x'; 300 * 1024]);
body.extend_from_slice(b"\r\n--B--\r\n");
let half = body.len() / 2;
let second: Vec<u8> = body.split_off(half);
// Three steps: first half, the side effect, second half.
let steps: Vec<Box<dyn FnOnce() -> Option<Vec<u8>> + Send>> = vec![
Box::new(move || Some(body)),
Box::new(move || {
between();
None
}),
Box::new(move || Some(second)),
];
let stream = futures_util::stream::unfold(steps.into_iter(), |mut it| async move {
loop {
let step = it.next()?;
match step() {
Some(chunk) => {
return Some((Ok::<_, std::io::Error>(axum::body::Bytes::from(chunk)), it));
}
// Let the server consume the first half before continuing.
None => tokio::task::yield_now().await,
}
}
});
let req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri(root_path(""))
.header("content-type", "multipart/form-data; boundary=B")
.header(
"cookie",
format!("fbng_session={}", admin.cookie.as_ref().unwrap()),
)
.body(axum::body::Body::from_stream(stream))
.unwrap();
let res = tower::ServiceExt::oneshot(env.app.clone(), req)
.await
.expect("request");
let status = res.status();
let bytes = http_body_util::BodyExt::collect(res.into_body())
.await
.unwrap()
.to_bytes();
(
status,
serde_json::from_slice(&bytes).unwrap_or(json!(null)),
)
}
/// The pre-upload stat said "does not exist". A file created while the body
/// streams in must still not be replaced: the publish step checks again,
/// atomically.
#[tokio::test]
async fn upload_does_not_clobber_a_file_created_during_the_transfer() {
let env = Env::new().await;
let admin = env.admin().await;
let target = env.file("raced.txt");
assert!(!target.exists());
let t = target.clone();
let (status, body) = upload_in_two_halves(&env, &admin, "raced.txt", move || {
std::fs::write(&t, b"someone else").unwrap();
})
.await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert_eq!(body["skipped"], json!(["raced.txt"]));
assert_eq!(std::fs::read(&target).unwrap(), b"someone else");
assert!(
!entries(&env).iter().any(|n| n.starts_with(".upload-")),
"scratch file left behind: {:?}",
entries(&env)
);
}
/// A multipart body that stops inside a part: the headers and part of the
/// payload, then end of stream with no closing boundary. That is what reaches
/// the server when the user closes the tab or the connection drops.
Mweb/Cargo.toml
@@ -17,10 +17,15 @@ wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"AddEventListenerOptions",
"BeforeUnloadEvent",
"Blob",
"Clipboard",
"DataTransfer",
"DataTransferItem",
"DataTransferItemList",
"Document",
"DomRect",
"DragEvent",
"Element",
"Event",
"EventSource",
@@ -28,6 +33,10 @@ web-sys = { version = "0.3", features = [
"EventListenerOptions",
"File",
"FileList",
"FileSystemDirectoryEntry",
"FileSystemDirectoryReader",
"FileSystemEntry",
"FileSystemFileEntry",
"HashChangeEvent",
"Headers",
"HtmlAnchorElement",
@@ -43,6 +52,9 @@ web-sys = { version = "0.3", features = [
"MouseEvent",
"Navigator",
"Node",
"Performance",
"PointerEvent",
"ProgressEvent",
"Request",
"RequestInit",
"RequestMode",
@@ -52,4 +64,7 @@ web-sys = { version = "0.3", features = [
"SvgElement",
"UrlSearchParams",
"Window",
"XmlHttpRequest",
"XmlHttpRequestEventTarget",
"XmlHttpRequestUpload",
] }
Mweb/app.css
@@ -646,10 +646,31 @@ button:disabled {
}
.entries-area {
position: relative;
flex: 1;
min-height: 240px;
}
.entries-area.drop-target {
outline: 2px dashed var(--accent);
outline-offset: -2px;
}
.drop-hint {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 500;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel) 60%);
/* The hint must not steal the drag from the area beneath it. */
pointer-events: none;
}
.center-note {
text-align: center;
padding: 48px 0;
@@ -689,6 +710,17 @@ button:disabled {
user-select: none;
}
/* Touch: a long press opens the context menu (see util::LongPress). The
iOS callout and text selection would otherwise appear first. Vertical
panning stays with the browser. */
.entries-area,
.tile,
.row {
-webkit-touch-callout: none;
-webkit-user-select: none;
touch-action: pan-y;
}
.tile:hover {
border-color: var(--muted);
}
@@ -2543,3 +2575,213 @@ mark.hl-hit {
color: var(--muted);
font-size: 14px;
}
/* ------------------------------------------------------------------ */
/* Uploads: overlay panel + log view */
/* ------------------------------------------------------------------ */
.upload-panel {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 190;
width: min(380px, calc(100vw - 40px));
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
font-size: 13px;
}
/* The toast sits above the panel when both show. */
.upload-panel ~ .toast,
.toast:has(~ .upload-panel) {
bottom: 80px;
}
.upload-panel-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.upload-panel.collapsed .upload-panel-head {
border-bottom: none;
}
.upload-panel-title {
font-weight: 600;
}
.upload-panel-summary {
margin-left: auto;
font-size: 12px;
}
.upload-panel-list {
max-height: 50vh;
overflow-y: auto;
}
.upload-job {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.upload-job:last-child {
border-bottom: none;
}
.upload-job-head {
display: flex;
align-items: center;
gap: 4px;
}
.upload-job-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.upload-job-pct {
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.upload-bar {
height: 4px;
margin: 6px 0 4px;
/* Clip the indeterminate block: it slides past both ends. */
overflow: hidden;
background: color-mix(in srgb, var(--accent) 15%, var(--panel));
}
.upload-bar-fill {
height: 100%;
background: var(--accent);
transition: width 0.2s linear;
}
/* Preparing: nothing to measure yet, so a sliding block instead of a fill. */
.upload-bar.indeterminate .upload-bar-fill {
width: 30% !important;
animation: upload-indeterminate 1.2s ease-in-out infinite;
}
@keyframes upload-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(333%);
}
}
.upload-job.failed .upload-bar-fill {
background: var(--danger);
}
.upload-job.done .upload-bar-fill {
background: var(--muted);
}
.upload-job-state {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-job.failed .upload-job-state {
color: var(--danger);
white-space: normal;
}
.upload-conflict {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
padding: 8px;
border: 1px solid var(--danger-border);
background: color-mix(in srgb, var(--danger) 8%, var(--panel));
}
.upload-conflict-all {
display: flex;
/* The label wraps on a narrow dialog; keep the box on the first line. */
align-items: flex-start;
gap: 0.5rem;
font-size: 12px;
cursor: pointer;
}
.upload-conflict-all input {
flex: none;
margin-top: 2px;
}
.modal .upload-conflict-all {
margin: 8px 0 0;
font-size: 13px;
}
.upload-conflict-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.upload-graph {
margin-top: 8px;
color: var(--accent);
border: 1px solid var(--border);
background: color-mix(in srgb, var(--accent) 6%, var(--panel));
}
/* Peak label above the plot, so the line can never run through it. */
.upload-graph-max {
display: block;
text-align: right;
padding: 2px 6px 0;
font-size: 11px;
line-height: 14px;
}
.upload-graph svg {
display: block;
width: 100%;
height: 46px;
}
.upload-errors {
margin: 6px 0 0;
padding-left: 18px;
font-size: 12px;
color: var(--danger);
}
.upload-err-file {
font-weight: 500;
}
/* Log view: the same rows, full width, roomier. */
.uploads-view .upload-log {
border: 1px solid var(--border);
background: var(--panel);
font-size: 14px;
}
.uploads-view .upload-job {
padding: 12px 14px;
}
.uploads-view .upload-graph svg {
height: 76px;
}
Mweb/src/api.rs
@@ -13,15 +13,15 @@ use wasm_bindgen::closure::Closure;
use wasm_bindgen_futures::JsFuture;
use api_types::{
ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_MKDIR, ACTION_PREVIEW,
ACTION_THUMB, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME,
AUTH_SETUP, CreateShare, CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT,
P_OVERWRITE, P_PATH, P_Q, P_ROOT, P_SCOPE, P_SHARE, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX,
SHARES, Settings, UnlockShare, UpdateUser,
ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_EXISTS, ACTION_MKDIR,
ACTION_PREVIEW, ACTION_THUMB, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN,
AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare, CreateUser, Credentials, ExistsReq, ExistsResp,
FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH, P_Q, P_ROOT, P_SCOPE, P_SHARE, Root,
SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, Settings, UnlockShare, UpdateUser,
};
pub use api_types::{
AdminShare, AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo,
UserInfo,
AdminShare, AdminUser, Entry, Existing, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp,
ShareInfo, UserInfo,
};
#[derive(Debug, thiserror::Error)]
@@ -104,12 +104,15 @@ struct ProfilePatch {
thumbnails: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
language: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
default_root_id: Option<Option<i64>>,
}
pub fn update_profile(
single_click_open: Option<bool>,
thumbnails: Option<bool>,
language: Option<Option<String>>,
default_root_id: Option<Option<i64>>,
) -> impl std::future::Future<Output = Result<Me, ApiError>> {
request(
"PUT",
@@ -118,6 +121,7 @@ pub fn update_profile(
single_click_open,
thumbnails,
language,
default_root_id,
}),
)
}
@@ -436,25 +440,20 @@ pub fn trigger_download(url: &str, filename: &str) {
a.remove();
}
/// Upload files into a directory. Each part is `(relative_path, file)`;
/// the relative path may contain subfolders (created on the server).
///
/// The multipart body is assembled by hand into a `Blob` (instead of using
/// `FormData` directly as the fetch body): a FormData body makes Chrome send
/// the request as a *streaming* body, which forces the HTTP/2-cleartext
/// Build a multipart body by hand into a `Blob` (instead of using
/// `FormData` directly as the request body): a FormData body makes Chrome
/// send the request as a *streaming* body, which forces the HTTP/2-cleartext
/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
/// it goes out as a regular length-prefixed HTTP/1.1 request.
pub async fn upload(
root_id: i64,
dir: &str,
overwrite: bool,
parts: Vec<(String, web_sys::File)>,
) -> Result<(), ApiError> {
///
/// Returns the body and its `Content-Type` header value.
fn multipart_blob(parts: &[(String, web_sys::File)]) -> Result<(web_sys::Blob, String), ApiError> {
let boundary = format!("----fbng{}", random_boundary_suffix());
let segments = js_sys::Array::new();
for (name, file) in &parts {
let basename = name.rsplit('/').next().unwrap_or(name);
for (name, file) in parts {
let basename = escape_cd(name.rsplit('/').next().unwrap_or(name));
let name = escape_cd(name);
segments.push(&JsValue::from_str(&format!(
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
@@ -467,29 +466,147 @@ pub async fn upload(
segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
.map_err(|e| ApiError::Net(js_msg(&e)))?;
Ok((body, format!("multipart/form-data; boundary={boundary}")))
}
/// Escape a multipart part name per the WHATWG form-data rules. The three
/// characters that would break out of the quoted `Content-Disposition`
/// value are percent-encoded; the server decodes them back.
fn escape_cd(s: &str) -> String {
s.replace('"', "%22")
.replace('\r', "%0D")
.replace('\n', "%0A")
}
/// Read-only upload pre-check: which of `paths` (relative to `dir`, may
/// contain subfolders) already exist, and whether each is a folder.
pub async fn check_exists(
root_id: i64,
dir: &str,
paths: Vec<String>,
) -> Result<Vec<Existing>, ApiError> {
let url = append_query(
&files_url(root_id, dir),
&format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }),
&format!("{P_ACTION}={ACTION_EXISTS}"),
);
// The server caps one request at 10 000 paths, the JSON body at 1 MB.
// A folder upload can bring far more (a kernel tree is ~80 000 files).
// Path length varies a lot, so the chunks are cut by bytes as well.
const CHUNK_BYTES: usize = 900_000;
const CHUNK_PATHS: usize = 10_000;
let mut existing = Vec::new();
let mut chunk: Vec<String> = Vec::new();
let mut bytes = 0usize;
for p in paths {
// Quotes, comma and escaping headroom around each path in the JSON.
bytes += p.len() + 8;
chunk.push(p);
if bytes >= CHUNK_BYTES || chunk.len() >= CHUNK_PATHS {
existing.extend(exists_chunk(&url, std::mem::take(&mut chunk)).await?);
bytes = 0;
}
}
if !chunk.is_empty() {
existing.extend(exists_chunk(&url, chunk).await?);
}
Ok(existing)
}
let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
headers
.set(
"Content-Type",
&format!("multipart/form-data; boundary={boundary}"),
)
async fn exists_chunk(url: &str, paths: Vec<String>) -> Result<Vec<Existing>, ApiError> {
let resp: ExistsResp = request("POST", url.to_string(), Some(ExistsReq { paths })).await?;
Ok(resp.existing)
}
/// Upload `files` into `dir` in one request, each as `(relative path,
/// file)`; subfolders are created on the server. The returned request can be
/// `abort()`ed; the future then resolves to [`ApiError::Net`], the same as a
/// lost connection. `on_progress` gets the file bytes sent so far (multipart
/// framing excluded). `overwrite=false` makes the server skip files that
/// exist and list them in a 409 after writing the rest; those are the files
/// that appeared during the transfer, since the pre-check covered the ones
/// that existed before.
pub fn start_upload(
root_id: i64,
dir: &str,
files: Vec<(String, web_sys::File)>,
overwrite: bool,
on_progress: impl Fn(f64) + 'static,
) -> Result<
(
web_sys::XmlHttpRequest,
impl std::future::Future<Output = Result<(), ApiError>>,
),
ApiError,
> {
let size: f64 = files.iter().map(|(_, f)| f.size()).sum();
let (body, content_type) = multipart_blob(&files)?;
let url = append_query(
&files_url(root_id, dir),
&format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }),
);
let xhr = web_sys::XmlHttpRequest::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
xhr.open_with_async("POST", &url, true)
.map_err(|e| ApiError::Net(js_msg(&e)))?;
xhr.set_request_header("Content-Type", &content_type)
.map_err(|e| ApiError::Net(js_msg(&e)))?;
let opts = web_sys::RequestInit::new();
opts.set_method("POST");
opts.set_mode(web_sys::RequestMode::SameOrigin);
opts.set_headers_headers(&headers);
opts.set_body_opt_blob(Some(&body));
let progress =
Closure::<dyn FnMut(web_sys::ProgressEvent)>::new(move |ev: web_sys::ProgressEvent| {
// `loaded` counts the whole multipart body, boundaries included.
// Scale it to file bytes so a tiny file never reads as 600 %.
let total = ev.total();
let file_bytes = if total > 0.0 {
(ev.loaded() / total * size).min(size)
} else {
ev.loaded().min(size)
};
on_progress(file_bytes);
});
if let Ok(up) = xhr.upload() {
up.set_onprogress(Some(progress.as_ref().unchecked_ref()));
}
// `loadend` fires for success, error and abort alike; the status tells
// them apart afterwards.
let done = js_sys::Promise::new(&mut |resolve, _reject| {
xhr.set_onloadend(Some(&resolve));
});
let req = xhr.clone();
xhr.send_with_opt_blob(Some(&body))
.map_err(|e| ApiError::Net(js_msg(&e)))?;
// The error carries the server's `skipped` list on an upload conflict.
fetch_checked(&url, &opts, "upload failed").await?;
Ok(())
let fut = async move {
let _ = JsFuture::from(done).await;
// Keep the progress listener alive until here.
drop(progress);
let status = xhr.status().unwrap_or(0);
if status == 0 {
// Our own abort and a dead network are indistinguishable here:
// both give status 0 and an empty status text. Report the
// network error; the caller knows whether it aborted.
return Err(ApiError::Net(
crate::i18n::t(crate::i18n::k::SERVER_UNREACHABLE).to_string(),
));
}
if (200..300).contains(&status) {
return Ok(());
}
let body: ErrBody = xhr
.response_text()
.ok()
.flatten()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
let fallback = body
.error
.clone()
.unwrap_or_else(|| "upload failed".to_string());
Err(ApiError::Http {
status,
message: crate::i18n::error_text(body.code.as_deref(), &fallback),
skipped: body.skipped,
})
};
Ok((req, fut))
}
// ---------------------------------------------------------------------------
@@ -675,6 +792,9 @@ pub fn pick_files(
return;
};
input.set_type("file");
// Off screen: the browser renders a bare "Choose files" control for an
// input in the body, and `click()` still works on a hidden one.
let _ = input.set_attribute("hidden", "");
if multiple {
input.set_multiple(true);
}
@@ -717,6 +837,171 @@ pub fn pick_files(
input2.click();
}
/// True when a drag carries files (not text or a link from the page).
pub fn drag_has_files(ev: &web_sys::DragEvent) -> bool {
ev.data_transfer()
.map(|dt| dt.types().includes(&JsValue::from_str("Files"), 0))
.unwrap_or(false)
}
/// The top-level items of a drop, read out of the `DataTransfer` while the
/// drop handler still runs. A `DataTransfer` is only readable during its own
/// event, so an async task that reads it later finds it empty. [`read_drop`]
/// therefore copies the entries and files out first.
pub struct Dropped {
entries: Vec<web_sys::FileSystemEntry>,
/// Flat list, for browsers without the entries API.
files: Vec<web_sys::File>,
}
impl Dropped {
/// Names of the top-level items, for a job label before the folders are
/// walked. A dropped folder shows up as one name here.
pub fn names(&self) -> Vec<String> {
if self.entries.is_empty() {
self.files.iter().map(|f| f.name()).collect()
} else {
self.entries.iter().map(|e| e.name()).collect()
}
}
}
/// Read a drop synchronously. See [`Dropped`].
pub fn read_drop(ev: &web_sys::DragEvent) -> Dropped {
let Some(dt) = ev.data_transfer() else {
return Dropped {
entries: Vec::new(),
files: Vec::new(),
};
};
let items = dt.items();
let mut entries = Vec::new();
for i in 0..items.length() {
if let Some(item) = items.get(i)
&& item.kind() == "file"
&& let Ok(Some(entry)) = item.webkit_get_as_entry()
{
entries.push(entry);
}
}
let mut files = Vec::new();
if let Some(list) = dt.files() {
for i in 0..list.length() {
if let Some(f) = list.get(i) {
files.push(f);
}
}
}
Dropped { entries, files }
}
/// The files of a drop as `(relative path, file)`. Dropped folders are
/// walked through the entries API, which is what gives them a path; the
/// plain `files` list flattens them to nothing. Browsers without that API
/// get the flat list.
///
/// Each directory's files are resolved in one `Promise.all`: `entry.file()`
/// is a round trip into the browser process, and 80 000 of them in a row
/// take minutes.
pub async fn files_from_drop(dropped: Dropped) -> Vec<(String, web_sys::File)> {
let Dropped { entries, files } = dropped;
let mut out = Vec::new();
if entries.is_empty() {
return files.into_iter().map(|f| (f.name(), f)).collect();
}
// Iterative walk: a stack instead of recursion keeps the future `Sized`.
// Top-level files are one batch; then each directory is one batch.
let mut dirs: Vec<(String, web_sys::FileSystemDirectoryEntry)> = Vec::new();
let mut files: Vec<(String, web_sys::FileSystemFileEntry)> = Vec::new();
for e in entries {
let name = e.name();
if e.is_directory() {
dirs.push((name, e.unchecked_into()));
} else if e.is_file() {
files.push((name, e.unchecked_into()));
}
}
out.extend(resolve_files(files).await);
while let Some((path, dir)) = dirs.pop() {
let reader = dir.create_reader();
let mut files = Vec::new();
// `readEntries` hands out batches (Chrome: 100) until an empty one.
loop {
let batch = read_entries(&reader).await;
if batch.is_empty() {
break;
}
for e in batch {
let sub = format!("{path}/{}", e.name());
if e.is_directory() {
dirs.push((sub, e.unchecked_into()));
} else if e.is_file() {
files.push((sub, e.unchecked_into()));
}
}
}
out.extend(resolve_files(files).await);
}
out
}
/// `entry.file()` for every entry at once. An entry that fails (vanished
/// mid-drop) is left out.
async fn resolve_files(
entries: Vec<(String, web_sys::FileSystemFileEntry)>,
) -> Vec<(String, web_sys::File)> {
if entries.is_empty() {
return Vec::new();
}
let promises = js_sys::Array::new();
for (_, entry) in &entries {
let p = js_sys::Promise::new(&mut |resolve, _reject| {
let resolve2 = resolve.clone();
let ok = Closure::once_into_js(move |f: web_sys::File| {
let _ = resolve2.call1(&JsValue::NULL, &f);
});
let err = Closure::once_into_js(move |_e: JsValue| {
let _ = resolve.call1(&JsValue::NULL, &JsValue::NULL);
});
entry.file_with_callback_and_callback(ok.unchecked_ref(), err.unchecked_ref());
});
promises.push(&p);
}
let all = match wasm_bindgen_futures::JsFuture::from(js_sys::Promise::all(&promises)).await {
Ok(a) => a,
Err(_) => return Vec::new(),
};
entries
.into_iter()
.zip(js_sys::Array::from(&all).iter())
.filter_map(|((path, _), v)| v.dyn_into::<web_sys::File>().ok().map(|f| (path, f)))
.collect()
}
async fn read_entries(
reader: &web_sys::FileSystemDirectoryReader,
) -> Vec<web_sys::FileSystemEntry> {
let p = js_sys::Promise::new(&mut |resolve, reject| {
let ok = Closure::once_into_js(move |arr: JsValue| {
let _ = resolve.call1(&JsValue::NULL, &arr);
});
let err = Closure::once_into_js(move |e: JsValue| {
let _ = reject.call1(&JsValue::NULL, &e);
});
let _ =
reader.read_entries_with_callback_and_callback(ok.unchecked_ref(), err.unchecked_ref());
});
match wasm_bindgen_futures::JsFuture::from(p).await {
Ok(arr) => js_sys::Array::from(&arr)
.iter()
// `unchecked_into`: Chromium has no global `FileSystemEntry`,
// so an `instanceof` check (`dyn_into`) fails for every entry.
.map(|v| v.unchecked_into())
.collect(),
Err(_) => Vec::new(),
}
}
// ---------------------------------------------------------------------------
// Low-level request helpers
// ---------------------------------------------------------------------------
Mweb/src/i18n.rs
@@ -211,6 +211,9 @@ i18n_keys! {
CREATE_USER = "create_user" => "Create user",
CREATING = "creating" => "Creating…",
CUSTOM = "custom" => "Custom…",
DEFAULT_ROOT_DESC = "default_root_desc" => "Open this folder on page load and on the home link.",
DEFAULT_ROOT_LABEL = "default_root_label" => "Default folder",
DEFAULT_ROOT_NONE = "default_root_none" => "Folder overview",
DELETE = "delete" => "Delete",
DELETE_ALL = "delete_all" => "Delete all",
DELETE_ALL_MSG = "delete_all_msg" => "Delete all {} share links? Anyone holding them loses access. This cannot be undone.",
@@ -260,6 +263,7 @@ i18n_keys! {
ERR_FS_ROOT_MISSING = "err_fs_root_missing" => "the configured folder no longer exists",
ERR_INTERNAL = "err_internal" => "internal error",
ERR_INVALID_CREDENTIALS = "err_invalid_credentials" => "invalid name or password",
ERR_INVALID_DEFAULT_ROOT = "err_invalid_default_root" => "the default folder is not one of your folders",
ERR_INVALID_LANGUAGE = "err_invalid_language" => "invalid language tag",
ERR_LAST_ADMIN = "err_last_admin" => "cannot remove the last active admin",
ERR_LAST_ADMIN_DELETE = "err_last_admin_delete" => "cannot delete the last active admin",
@@ -476,10 +480,35 @@ i18n_keys! {
UNLOCK = "unlock" => "Open share",
UNSAVED_CHANGES = "unsaved_changes" => "Unsaved changes",
UNTIL = "until" => "Until",
UPLOAD_ABORT = "upload_abort" => "Abort",
UPLOAD_ABORTED = "upload_aborted" => "Aborted",
UPLOAD_ACTIVE_N = "upload_active_n" => "{} active",
UPLOAD_APPLY_ALL = "upload_apply_all" => "Apply to files that appear during the upload",
UPLOAD_CHECK_FAILED = "upload_check_failed" => "Could not check for existing files: {}",
UPLOAD_CLEAR = "upload_clear" => "Remove from list",
UPLOAD_CLEAR_FINISHED = "upload_clear_finished" => "Clear finished",
UPLOAD_COLLAPSE = "upload_collapse" => "Collapse",
UPLOAD_COMPLETE = "upload_complete" => "Upload complete",
UPLOAD_CONFLICT_QUESTION = "upload_conflict_question" => "The following files already exist. Overwrite or skip them?",
UPLOAD_DROP_HINT = "upload_drop_hint" => "Drop to upload here",
UPLOAD_ERRORS_N = "upload_errors_n" => "{} failed",
UPLOAD_EXISTS_ASK = "upload_exists_ask" => "“{}” appeared on the server during the upload.",
UPLOAD_EXPAND = "upload_expand" => "Expand",
UPLOAD_FAILED = "upload_failed" => "Failed",
UPLOAD_FILES = "upload_files" => "Upload files",
UPLOAD_FOLDER = "upload_folder" => "Upload folder",
UPLOADING = "uploading" => "Uploading {} files…",
UPLOAD_N_FILES = "upload_n_files" => "{} files",
UPLOAD_PAUSE = "upload_pause" => "Pause",
UPLOAD_PAUSED = "upload_paused" => "Paused",
UPLOAD_PREPARING = "upload_preparing" => "Preparing…",
UPLOAD_RESUME = "upload_resume" => "Resume",
UPLOAD_SHOW_GRAPH = "upload_show_graph" => "Details and speed graph",
UPLOAD_SKIP = "upload_skip" => "Skip",
UPLOAD_SKIPPED_N = "upload_skipped_n" => "{} skipped",
UPLOAD_SPEED = "upload_speed" => "{}/s",
UPLOAD_WAITING = "upload_waiting" => "Waiting for your answer",
UPLOADS = "uploads" => "Uploads",
UPLOADS_EMPTY = "uploads_empty" => "No uploads yet",
USER_CREATED = "user_created" => "User created",
USER_DELETE_ERR = "user_delete_err" => "Could not delete user",
USER_SAVE_ERR = "user_save_err" => "Could not save user: {}",
@@ -524,6 +553,12 @@ const DE: &[(&str, &str)] = &[
("create_user", "Benutzer erstellen"),
("creating", "Wird erstellt…"),
("custom", "Benutzerdefiniert…"),
(
"default_root_desc",
"Diesen Ordner beim Laden der Seite und über den Home-Link öffnen.",
),
("default_root_label", "Standardordner"),
("default_root_none", "Ordnerübersicht"),
("delete", "Löschen"),
("delete_all", "Alle löschen"),
(
@@ -618,6 +653,10 @@ const DE: &[(&str, &str)] = &[
),
(k::ERR_INTERNAL, "interner Fehler"),
(k::ERR_INVALID_CREDENTIALS, "Name oder Passwort ungültig"),
(
k::ERR_INVALID_DEFAULT_ROOT,
"Der Standardordner gehört nicht zu Ihren Ordnern",
),
(k::ERR_INVALID_LANGUAGE, "ungültiges Sprachkürzel"),
(
k::ERR_LAST_ADMIN,
@@ -975,10 +1014,47 @@ const DE: &[(&str, &str)] = &[
("unlock", "Freigabe öffnen"),
("unsaved_changes", "Ungespeicherte Änderungen"),
("until", "Bis"),
("upload_abort", "Abbrechen"),
("upload_aborted", "Abgebrochen"),
("upload_active_n", "{} aktiv"),
(
"upload_apply_all",
"Auch auf Dateien anwenden, die während des Uploads entstehen",
),
(
"upload_check_failed",
"Prüfung auf vorhandene Dateien fehlgeschlagen: {}",
),
("upload_clear", "Aus der Liste entfernen"),
("upload_clear_finished", "Abgeschlossene entfernen"),
("upload_collapse", "Einklappen"),
("upload_complete", "Hochladen abgeschlossen"),
(
"upload_conflict_question",
"Die folgenden Dateien existieren bereits. Überschreiben oder überspringen?",
),
("upload_drop_hint", "Zum Hochladen hier ablegen"),
("upload_errors_n", "{} fehlgeschlagen"),
(
"upload_exists_ask",
"„{}“ ist während des Uploads auf dem Server entstanden.",
),
("upload_expand", "Ausklappen"),
("upload_failed", "Fehlgeschlagen"),
("upload_files", "Dateien hochladen"),
("upload_folder", "Ordner hochladen"),
("uploading", "{} Datei(en) werden hochgeladen…"),
("upload_n_files", "{} Dateien"),
("upload_pause", "Pausieren"),
("upload_paused", "Pausiert"),
("upload_preparing", "Wird vorbereitet…"),
("upload_resume", "Fortsetzen"),
("upload_show_graph", "Details und Geschwindigkeitsverlauf"),
("upload_skip", "Überspringen"),
("upload_skipped_n", "{} übersprungen"),
("upload_speed", "{}/s"),
("upload_waiting", "Wartet auf Ihre Antwort"),
("uploads", "Uploads"),
("uploads_empty", "Noch keine Uploads"),
("user_created", "Benutzer erstellt"),
("user_delete_err", "Benutzer konnte nicht gelöscht werden"),
(
@@ -1032,6 +1108,12 @@ const FR: &[(&str, &str)] = &[
("create_user", "Créer l'utilisateur"),
("creating", "Création…"),
("custom", "Personnalisé…"),
(
"default_root_desc",
"Ouvrir ce dossier au chargement de la page et via le lien d'accueil.",
),
("default_root_label", "Dossier par défaut"),
("default_root_none", "Aperçu des dossiers"),
("delete", "Supprimer"),
("delete_all", "Tout supprimer"),
(
@@ -1120,6 +1202,10 @@ const FR: &[(&str, &str)] = &[
(k::ERR_FS_ROOT_MISSING, "le dossier configuré n'existe plus"),
(k::ERR_INTERNAL, "erreur interne"),
(k::ERR_INVALID_CREDENTIALS, "nom ou mot de passe incorrect"),
(
k::ERR_INVALID_DEFAULT_ROOT,
"le dossier par défaut ne fait pas partie de vos dossiers",
),
(k::ERR_INVALID_LANGUAGE, "balise de langue invalide"),
(
k::ERR_LAST_ADMIN,
@@ -1474,10 +1560,47 @@ const FR: &[(&str, &str)] = &[
("unlock", "Ouvrir le partage"),
("unsaved_changes", "Modifications non enregistrées"),
("until", "Jusqu'au"),
("upload_abort", "Interrompre"),
("upload_aborted", "Interrompu"),
("upload_active_n", "{} en cours"),
(
"upload_apply_all",
"Appliquer aussi aux fichiers qui apparaissent pendant le téléversement",
),
(
"upload_check_failed",
"Impossible de vérifier les fichiers existants : {}",
),
("upload_clear", "Retirer de la liste"),
("upload_clear_finished", "Effacer les terminés"),
("upload_collapse", "Réduire"),
("upload_complete", "Téléversement terminé"),
(
"upload_conflict_question",
"Les fichiers suivants existent déjà. Les remplacer ou les ignorer ?",
),
("upload_drop_hint", "Déposer ici pour téléverser"),
("upload_errors_n", "{} en échec"),
(
"upload_exists_ask",
"« {} » est apparu sur le serveur pendant le téléversement.",
),
("upload_expand", "Développer"),
("upload_failed", "Échec"),
("upload_files", "Téléverser des fichiers"),
("upload_folder", "Téléverser un dossier"),
("uploading", "Téléversement de {} fichier(s)…"),
("upload_n_files", "{} fichiers"),
("upload_pause", "Mettre en pause"),
("upload_paused", "En pause"),
("upload_preparing", "Préparation…"),
("upload_resume", "Reprendre"),
("upload_show_graph", "Détails et graphique de vitesse"),
("upload_skip", "Ignorer"),
("upload_skipped_n", "{} ignorés"),
("upload_speed", "{}/s"),
("upload_waiting", "En attente de votre réponse"),
("uploads", "Téléversements"),
("uploads_empty", "Aucun téléversement pour l'instant"),
("user_created", "Utilisateur créé"),
("user_delete_err", "Impossible de supprimer l'utilisateur"),
(
Mweb/src/icons.rs
@@ -97,6 +97,14 @@ pub enum IconName {
SortAsc,
/// material-symbols:arrow-downward
SortDesc,
/// material-symbols:pause
Pause,
/// material-symbols:play-arrow
Play,
/// material-symbols:expand-more
ExpandMore,
/// material-symbols:expand-less
ExpandLess,
}
impl IconName {
@@ -223,6 +231,14 @@ impl IconName {
Self::ChevronRight => {
r#"<path fill="currentColor" d="M12.6 12L8 7.4L9.4 6l6 6l-6 6L8 16.6z"/>"#
}
Self::Pause => r#"<path fill="currentColor" d="M14 19V5h4v14zm-8 0V5h4v14z"/>"#,
Self::Play => r#"<path fill="currentColor" d="M8 19V5l11 7z"/>"#,
Self::ExpandMore => {
r#"<path fill="currentColor" d="m12 15.4l-6-6L7.4 8l4.6 4.6L16.6 8L18 9.4z"/>"#
}
Self::ExpandLess => {
r#"<path fill="currentColor" d="m7.4 15.375l-1.4-1.4l6-6l6 6l-1.4 1.4l-4.6-4.6z"/>"#
}
Self::SortAsc => {
r#"<path fill="currentColor" d="M11 20V7.825l-5.6 5.6L4 12l8-8l8 8l-1.4 1.425l-5.6-5.6V20z"/>"#
}
Mweb/src/main.rs
@@ -12,6 +12,7 @@ mod icons;
mod preview;
mod router;
mod theme;
mod uploads;
mod util;
mod views;
Mweb/src/router.rs
@@ -3,7 +3,8 @@
//! - `#/` — root picker / single-root browser
//! - `#/files/{root_id}/{segment/...}` — a user's folder
//! - `#/share/{token}/{segment/...}` — a public share (no login)
//! - `#/shares`, `#/users`, `#/settings` — the signed-in shell's manage views
//! - `#/shares`, `#/users`, `#/settings`, `#/uploads` — the signed-in
//! shell's manage views
//!
//! The URL hash is the single source of truth; the `Location` signal in the
//! app mirrors it.
@@ -18,6 +19,8 @@ pub enum Section {
Shares,
Users,
Settings,
/// This session's upload jobs (`#/uploads`).
Uploads,
}
impl Section {
@@ -30,6 +33,7 @@ impl Section {
Section::Shares => Some("shares"),
Section::Users => Some("users"),
Section::Settings => Some("settings"),
Section::Uploads => Some("uploads"),
}
}
@@ -41,6 +45,7 @@ impl Section {
Section::Shares,
Section::Users,
Section::Settings,
Section::Uploads,
]
.into_iter()
.find(|s| s.segment() == Some(seg))
Aweb/src/uploads.rs
@@ -0,0 +1,760 @@
//! Upload jobs: one job per pick (files or a folder), files sent one request
//! each so progress, pause and abort work per file.
//!
//! The job list is a page-global signal (wasm is single-threaded, so a
//! `thread_local` is the whole "manager"). The overlay panel and the uploads
//! view both render it; the browser starts jobs; the runner below drives
//! them.
//!
//! Pause aborts the in-flight request and restarts that file on resume.
//! HTTP cannot pause one request.
//! ponytail: per-file restart on resume; chunked resumable uploads if
//! multi-GB files matter.
use std::cell::RefCell;
use std::collections::{HashSet, VecDeque};
use std::rc::Rc;
use std::time::Duration;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::i18n;
/// What to do when a file turns out to exist after all (the pre-check said
/// no, someone created it during the transfer).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum OnConflict {
/// Pause the job on that file and ask in the panel.
Ask,
Overwrite,
Skip,
}
#[derive(Clone, PartialEq, Debug)]
pub enum JobState {
/// Files picked, existence check in flight. No bytes move yet.
Preparing,
Running,
Paused,
/// Waiting for the user's answer about this file (relative path).
Conflict(String),
Done,
Failed(String),
Aborted,
}
impl JobState {
pub fn is_active(&self) -> bool {
matches!(
self,
JobState::Preparing | JobState::Running | JobState::Paused | JobState::Conflict(_)
)
}
}
#[derive(Clone, PartialEq)]
pub struct Job {
pub id: u64,
pub root_id: i64,
/// Target directory relative to the root ("" = the root).
pub dir: String,
/// "name.txt" for one file, the folder name for a folder pick, else
/// "N files".
pub label: String,
/// Number of files in the job. The file list itself stays with the
/// runner: a folder pick can bring 80 000 entries, and every progress
/// update clones this struct for the row views.
pub file_count: usize,
pub total: f64,
/// Bytes of the files that finished (written or skipped).
pub done_bytes: f64,
/// Bytes sent of the in-flight file.
pub cur_loaded: f64,
/// Index of the in-flight file.
pub current: usize,
/// Relative path of the in-flight file.
pub current_name: String,
pub state: JobState,
pub on_conflict: OnConflict,
pub skipped: Vec<String>,
/// Files that failed for good, with the server's message.
pub errors: Vec<(String, String)>,
/// (ms since page load, bytes so far), newest last. ~500 ms apart.
pub samples: VecDeque<(f64, f64)>,
pub started_ms: f64,
pub finished_ms: Option<f64>,
/// Speed graph open (per row).
pub expanded: bool,
/// Left the overlay (finished a while ago). Still in the uploads view.
pub hidden: bool,
}
impl Job {
pub fn sent(&self) -> f64 {
self.done_bytes + self.cur_loaded
}
pub fn percent(&self) -> f64 {
if self.total <= 0.0 {
return if self.state == JobState::Done {
100.0
} else {
0.0
};
}
(self.sent() / self.total * 100.0).clamp(0.0, 100.0)
}
/// Bytes per second over the last two samples; 0 while not running.
pub fn speed(&self) -> f64 {
if self.state != JobState::Running || self.samples.len() < 2 {
return 0.0;
}
let n = self.samples.len();
speeds(&[self.samples[n - 2], self.samples[n - 1]])
.last()
.copied()
.unwrap_or(0.0)
}
pub fn is_finished(&self) -> bool {
!self.state.is_active()
}
}
/// Bytes per second for each interval between consecutive `(ms, bytes)`
/// samples. One shorter than `samples`, empty for fewer than two.
pub fn speeds(samples: &[(f64, f64)]) -> Vec<f64> {
samples
.windows(2)
.map(|w| {
let ((t0, b0), (t1, b1)) = (w[0], w[1]);
if t1 > t0 {
(b1 - b0) / (t1 - t0) * 1000.0
} else {
0.0
}
})
.collect()
}
/// Runtime handles the runner and the panel share, outside the signal: the
/// in-flight request and the user's pending answers.
#[derive(Default)]
struct Control {
paused: bool,
abort: bool,
req: Option<web_sys::XmlHttpRequest>,
/// The answer for the file the job is waiting on.
decision: Option<OnConflict>,
}
/// The page's "folder changed" hook (see [`set_on_done`]).
type OnDone = Rc<dyn Fn(i64, &str)>;
thread_local! {
/// Owner of the global signals below. A signal created inside a
/// component belongs to that component and dies with it on navigation;
/// this root owner lives as long as the page.
static ROOT: Owner = new_detached_owner();
static JOBS: RwSignal<Vec<Job>, LocalStorage> =
ROOT.with(|o| o.with(|| RwSignal::new_local(Vec::new())));
static CONTROLS: RefCell<Vec<(u64, Rc<RefCell<Control>>)>> = const { RefCell::new(Vec::new()) };
static NEXT_ID: RefCell<u64> = const { RefCell::new(1) };
/// Overlay collapsed to its header line.
static COLLAPSED: RwSignal<bool> = ROOT.with(|o| o.with(|| RwSignal::new(false)));
/// Called with (root_id, dir) when a job finishes, so the page can
/// re-list that folder if it is the one shown.
static ON_DONE: RefCell<Option<(u64, OnDone)>> = const { RefCell::new(None) };
}
/// `Owner::new_root` makes itself the current owner as a side effect. This
/// runs lazily from inside a component, so put the previous owner back.
fn new_detached_owner() -> Owner {
let prev = Owner::current();
let root = Owner::new_root(None);
match prev {
Some(p) => p.set(),
None => root.clone().unset(),
}
root
}
const SAMPLE_MS: f64 = 500.0;
const SAMPLES_KEPT: usize = 60;
/// How long a finished job stays in the overlay.
const OVERLAY_LINGER: Duration = Duration::from_secs(5);
pub fn jobs() -> RwSignal<Vec<Job>, LocalStorage> {
JOBS.with(|j| *j)
}
pub fn collapsed() -> RwSignal<bool> {
COLLAPSED.with(|c| *c)
}
/// Register the page's "folder changed" hook. One per page; the last one
/// wins. Returns the token to hand to [`clear_on_done`].
pub fn set_on_done(f: impl Fn(i64, &str) + 'static) -> u64 {
let token = next_id();
ON_DONE.with(|h| *h.borrow_mut() = Some((token, Rc::new(f))));
token
}
/// Drop the hook again. The page that registered it owns the signals the
/// closure captures; a job finishing after that page is gone would panic.
/// A token that is no longer the current one is ignored: the next page may
/// register its hook before this one is cleaned up.
pub fn clear_on_done(token: u64) {
ON_DONE.with(|h| {
let mut h = h.borrow_mut();
if h.as_ref().is_some_and(|(t, _)| *t == token) {
*h = None;
}
});
}
pub fn any_active() -> bool {
jobs().with(|j| j.iter().any(|j| j.state.is_active()))
}
/// Next value of the page-wide counter, for job ids and hook tokens.
fn next_id() -> u64 {
NEXT_ID.with(|n| {
let mut n = n.borrow_mut();
*n += 1;
*n - 1
})
}
fn now_ms() -> f64 {
web_sys::window()
.and_then(|w| w.performance())
.map(|p| p.now())
.unwrap_or(0.0)
}
fn control(id: u64) -> Option<Rc<RefCell<Control>>> {
CONTROLS.with(|c| {
c.borrow()
.iter()
.find(|(i, _)| *i == id)
.map(|(_, c)| c.clone())
})
}
fn update(id: u64, f: impl FnOnce(&mut Job)) {
jobs().update(|list| {
if let Some(j) = list.iter_mut().find(|j| j.id == id) {
f(j);
}
});
}
fn read<T>(id: u64, f: impl FnOnce(&Job) -> T) -> Option<T> {
jobs().with_untracked(|list| list.iter().find(|j| j.id == id).map(f))
}
// ---------------------------------------------------------------------------
// Starting and controlling jobs
// ---------------------------------------------------------------------------
/// Create a job in the `Preparing` state so the panel shows it at once.
/// The existence check and the conflict dialog run in between; then
/// [`begin`] starts the transfer, or [`discard`] drops the job again.
pub fn prepare(root_id: i64, dir: String, label: String) -> u64 {
let id = next_id();
let started_ms = now_ms();
let job = Job {
id,
root_id,
dir,
label,
file_count: 0,
total: 0.0,
done_bytes: 0.0,
cur_loaded: 0.0,
current: 0,
current_name: String::new(),
state: JobState::Preparing,
on_conflict: OnConflict::Ask,
skipped: Vec::new(),
errors: Vec::new(),
samples: VecDeque::from([(started_ms, 0.0)]),
started_ms,
finished_ms: None,
expanded: false,
hidden: false,
};
CONTROLS.with(|c| {
c.borrow_mut()
.push((id, Rc::new(RefCell::new(Control::default()))))
});
jobs().update(|list| list.push(job));
id
}
/// Start the transfer of a prepared job. `files` are `(relative path,
/// file)`; `errors` are files rejected before the start (a folder sits where
/// the file would go). `on_conflict` is the pre-check dialog's choice for
/// files that appear during the transfer; `decided` are the files the
/// dialog already covered. They are overwritten or skipped per
/// `decided_overwrite` without a first attempt. A job the user aborted
/// while preparing stays discarded.
pub fn begin(
id: u64,
files: Vec<(String, web_sys::File)>,
errors: Vec<(String, String)>,
on_conflict: OnConflict,
decided: Vec<String>,
decided_overwrite: bool,
) {
let Some(ctl) = control(id) else { return };
let Some((root_id, dir)) = read(id, |j| (j.root_id, j.dir.clone())) else {
return;
};
let total = files.iter().map(|(_, f)| f.size()).sum();
let started_ms = now_ms();
update(id, |j| {
j.file_count = files.len();
j.total = total;
j.state = JobState::Running;
j.on_conflict = on_conflict;
j.errors = errors;
j.samples = VecDeque::from([(started_ms, 0.0)]);
j.started_ms = started_ms;
});
spawn_local(run(
id,
root_id,
dir,
files,
ctl,
on_conflict,
(decided, decided_overwrite),
));
spawn_local(sample(id));
}
/// Replace the provisional label of a preparing job.
pub fn set_label(id: u64, label: String) {
update(id, |j| j.label = label);
}
/// Drop a job that never started (check failed, dialog cancelled).
pub fn discard(id: u64) {
jobs().update(|list| list.retain(|j| j.id != id));
CONTROLS.with(|c| c.borrow_mut().retain(|(i, _)| *i != id));
}
/// Push one speed sample every `SAMPLE_MS` while the job is active. A
/// fixed clock, not "per file" or "per progress event": thousands of small
/// files would otherwise redraw the graph hundreds of times a second.
async fn sample(id: u64) {
loop {
sleep(SAMPLE_MS as u64).await;
let active = read(id, |j| j.state.is_active());
if active != Some(true) {
return;
}
update(id, |j| {
let sent = j.sent();
j.samples.push_back((now_ms(), sent));
if j.samples.len() > SAMPLES_KEPT {
j.samples.pop_front();
}
});
}
}
pub fn pause(id: u64) {
let Some(ctl) = control(id) else { return };
let mut c = ctl.borrow_mut();
c.paused = true;
if let Some(r) = &c.req {
let _ = r.abort();
}
drop(c);
update(id, |j| {
if j.state == JobState::Running {
j.state = JobState::Paused;
j.cur_loaded = 0.0;
}
});
}
pub fn resume(id: u64) {
let Some(ctl) = control(id) else { return };
ctl.borrow_mut().paused = false;
update(id, |j| {
if j.state == JobState::Paused {
j.state = JobState::Running;
}
});
}
pub fn abort(id: u64) {
if read(id, |j| j.state == JobState::Preparing) == Some(true) {
discard(id);
return;
}
let Some(ctl) = control(id) else { return };
let mut c = ctl.borrow_mut();
c.abort = true;
c.paused = false;
if let Some(r) = &c.req {
let _ = r.abort();
}
// The runner marks the job Aborted when it notices.
}
/// Answer the conflict prompt of a job. `apply_all` makes `choice` the
/// job's rule for later conflicts too.
pub fn decide(id: u64, choice: OnConflict, apply_all: bool) {
let Some(ctl) = control(id) else { return };
let mut c = ctl.borrow_mut();
c.decision = Some(choice);
// Pausing while the prompt was up left the flag set: the gate blocks
// the next batch, so the row must say Paused, not Running.
let paused = c.paused;
drop(c);
update(id, |j| {
if apply_all {
j.on_conflict = choice;
}
if matches!(j.state, JobState::Conflict(_)) {
j.state = if paused {
JobState::Paused
} else {
JobState::Running
};
}
});
}
pub fn toggle_expanded(id: u64) {
update(id, |j| j.expanded = !j.expanded);
}
/// Remove a finished job from the log.
pub fn clear(id: u64) {
jobs().update(|list| list.retain(|j| j.id != id || j.state.is_active()));
CONTROLS.with(|c| c.borrow_mut().retain(|(i, _)| *i != id));
}
pub fn clear_finished() {
let ids: Vec<u64> = jobs().with_untracked(|list| {
list.iter()
.filter(|j| j.is_finished())
.map(|j| j.id)
.collect()
});
for id in ids {
clear(id);
}
}
// ---------------------------------------------------------------------------
// Runner
// ---------------------------------------------------------------------------
async fn sleep(ms: u64) {
gloo_timers::future::sleep(Duration::from_millis(ms)).await;
}
/// Files per request: files are grouped until the group holds
/// `BATCH_BYTES` or `BATCH_FILES`, whichever comes first. A kernel tree
/// (80 000 small files) becomes a few hundred requests instead of 80 000.
/// A file above the byte cap travels alone.
const BATCH_BYTES: f64 = 1024.0 * 1024.0;
const BATCH_FILES: usize = 200;
/// One request's worth of files, plus the overwrite flag they all share.
type Batch = (Vec<(String, web_sys::File)>, bool);
/// Cut `items` into groups at both caps. `size` reads an item's bytes.
/// Generic over the item so the caps can be tested without a `web_sys::File`
/// (which needs a browser).
fn chunk<T>(items: Vec<T>, size: impl Fn(&T) -> f64) -> Vec<Vec<T>> {
let mut out: Vec<Vec<T>> = Vec::new();
let mut bytes = 0.0;
for it in items {
let s = size(&it);
let full = out
.last()
.is_some_and(|b| bytes + s > BATCH_BYTES || b.len() >= BATCH_FILES);
if out.is_empty() || full {
out.push(Vec::new());
bytes = 0.0;
}
bytes += s;
out.last_mut().expect("pushed above").push(it);
}
out
}
/// Group `files` into request batches. `decided` holds the paths the
/// pre-check dialog listed. With `decided_overwrite` they go into batches
/// that carry the overwrite flag. Otherwise they come back as `(path, size)`
/// and are never sent.
fn plan_batches(
files: Vec<(String, web_sys::File)>,
decided: &HashSet<String>,
decided_overwrite: bool,
) -> (VecDeque<Batch>, Vec<(String, f64)>) {
let mut skipped = Vec::new();
let (mut overwrite, mut plain) = (Vec::new(), Vec::new());
for (rel, file) in files {
match (decided.contains(&rel), decided_overwrite) {
(true, false) => skipped.push((rel, file.size())),
(true, true) => overwrite.push((rel, file)),
(false, _) => plain.push((rel, file)),
}
}
let size = |(_, f): &(String, web_sys::File)| f.size();
let queue = chunk(overwrite, size)
.into_iter()
.map(|b| (b, true))
.chain(chunk(plain, size).into_iter().map(|b| (b, false)))
.collect();
(queue, skipped)
}
async fn run(
id: u64,
root_id: i64,
dir: String,
files: Vec<(String, web_sys::File)>,
ctl: Rc<RefCell<Control>>,
on_conflict: OnConflict,
decided: (Vec<String>, bool),
) {
let mut rule = on_conflict;
let (decided, decided_overwrite) = decided;
let decided: HashSet<String> = decided.into_iter().collect();
let (mut queue, pre_skipped) = plan_batches(files, &decided, decided_overwrite);
let mut done_count = pre_skipped.len();
if !pre_skipped.is_empty() {
update(id, |j| {
for (rel, size) in pre_skipped {
j.skipped.push(rel);
j.done_bytes += size;
}
});
}
while let Some(batch) = queue.pop_front() {
// Pause / abort gate.
// ponytail: 200 ms poll, wake channel if latency matters.
while ctl.borrow().paused && !ctl.borrow().abort {
sleep(200).await;
}
if ctl.borrow().abort {
finish(id, JobState::Aborted, &dir);
return;
}
let (files, batch_ow) = batch;
let total: f64 = files.iter().map(|(_, f)| f.size()).sum();
update(id, |j| {
j.current = done_count;
j.current_name = files[0].0.clone();
j.cur_loaded = 0.0;
});
let on_progress = move |loaded: f64| {
update(id, |j| j.cur_loaded = loaded);
};
let started = api::start_upload(root_id, &dir, files.clone(), batch_ow, on_progress);
let (req, fut) = match started {
Ok(x) => x,
Err(e) => {
finish(id, JobState::Failed(e.to_string()), &dir);
return;
}
};
ctl.borrow_mut().req = Some(req);
let res = fut.await;
ctl.borrow_mut().req = None;
match res {
Ok(()) => {
done_count += files.len();
update(id, |j| {
j.done_bytes += total;
j.cur_loaded = 0.0;
});
}
// Our own abort (pause or stop) cannot be told from a network
// failure by the response alone, so the control flags decide.
// The gate at the top of the loop handles it; the whole batch
// goes again, and it is at most ~1 MiB.
Err(_) if ctl.borrow().paused || ctl.borrow().abort => {
update(id, |j| j.cur_loaded = 0.0);
queue.push_front((files, batch_ow));
}
Err(e) if e.status() == Some(409) && e.skipped().is_some() => {
// Some files appeared during the transfer. The server wrote
// the others; only the listed ones need an answer.
let hit: HashSet<&str> = e
.skipped()
.unwrap_or_default()
.iter()
.map(String::as_str)
.collect();
let (conflicts, written): (Vec<_>, Vec<_>) = files
.into_iter()
.partition(|(rel, _)| hit.contains(rel.as_str()));
done_count += written.len();
let written_bytes: f64 = written.iter().map(|(_, f)| f.size()).sum();
update(id, |j| {
j.done_bytes += written_bytes;
j.cur_loaded = 0.0;
});
let mut overwrite = Vec::new();
for (rel, file) in conflicts {
let size = file.size();
if batch_ow {
// An overwrite request only ever reports a skip when
// a folder sits where the file would go. Sending it
// again would loop, so it fails here.
done_count += 1;
update(id, |j| {
j.errors
.push((rel, i18n::t(i18n::k::ERR_FOLDER_EXISTS).to_string()));
j.done_bytes += size;
});
continue;
}
let choice = match rule {
OnConflict::Ask => {
update(id, |j| j.state = JobState::Conflict(rel.clone()));
// A stalled job must be seen: open the overlay.
collapsed().set(false);
let choice = wait_decision(&ctl).await;
if ctl.borrow().abort {
finish(id, JobState::Aborted, &dir);
return;
}
// `decide` may have made this the job's rule.
rule = read(id, |j| j.on_conflict).unwrap_or(rule);
choice
}
r => r,
};
match choice {
OnConflict::Skip => {
done_count += 1;
update(id, |j| {
j.skipped.push(rel);
j.done_bytes += size;
});
}
_ => overwrite.push((rel, file)),
}
}
if !overwrite.is_empty() {
queue.push_front((overwrite, true));
}
}
Err(e) if e.status() == Some(409) && files.len() > 1 => {
// The server rejected the whole request with a conflict it
// could not attribute. Retry one by one so the error lands
// on the file that caused it.
// ponytail: files before the culprit were already written;
// their single retries come back as "appeared during the
// transfer" and may prompt. Rare: the pre-check catches
// folders unless one was created mid-upload.
let _ = e;
for f in files.into_iter().rev() {
queue.push_front((vec![f], batch_ow));
}
}
Err(e) if e.status() == Some(409) => {
// A folder sits where the file would go. Per-file
// failure; the job goes on.
done_count += 1;
let (rel, file) = &files[0];
let size = file.size();
update(id, |j| {
j.errors.push((rel.clone(), e.to_string()));
j.done_bytes += size;
j.cur_loaded = 0.0;
});
}
Err(e) => {
finish(id, JobState::Failed(e.to_string()), &dir);
return;
}
}
}
finish(id, JobState::Done, &dir);
}
async fn wait_decision(ctl: &Rc<RefCell<Control>>) -> OnConflict {
// ponytail: 200 ms poll, wake channel if latency matters.
loop {
if ctl.borrow().abort {
return OnConflict::Skip;
}
if let Some(d) = ctl.borrow_mut().decision.take() {
return d;
}
sleep(200).await;
}
}
fn finish(id: u64, state: JobState, dir: &str) {
let root_id = read(id, |j| j.root_id);
update(id, |j| {
j.state = state;
j.cur_loaded = 0.0;
j.finished_ms = Some(now_ms());
});
if let Some(root_id) = root_id {
ON_DONE.with(|h| {
if let Some((_, f)) = h.borrow().as_ref() {
f(root_id, dir);
}
});
}
spawn_local(async move {
gloo_timers::future::sleep(OVERLAY_LINGER).await;
update(id, |j| j.hidden = true);
});
}
/// "12.3 MB/s".
pub fn format_speed(bytes_per_s: f64) -> String {
i18n::t_fmt(
i18n::k::UPLOAD_SPEED,
&crate::util::format_size(bytes_per_s.max(0.0) as u64),
)
}
#[cfg(test)]
mod tests {
use super::{BATCH_BYTES, BATCH_FILES, chunk};
/// Fake sizes: a real `web_sys::File` needs a browser.
#[test]
fn chunk_respects_both_caps() {
let sizes = |v: Vec<f64>| chunk(v, |s: &f64| *s);
let lens = |b: Vec<Vec<f64>>| b.iter().map(Vec::len).collect::<Vec<_>>();
// The byte cap cuts before the group would exceed it.
assert_eq!(
lens(sizes(vec![BATCH_BYTES * 0.6, BATCH_BYTES * 0.6, 1.0])),
vec![1, 2]
);
// A file above the cap travels alone.
assert_eq!(
lens(sizes(vec![1.0, BATCH_BYTES * 3.0, 1.0])),
vec![1, 1, 1]
);
// The file cap cuts even when the bytes are nothing.
assert_eq!(
lens(sizes(vec![0.0; BATCH_FILES * 2 + 1])),
vec![BATCH_FILES, BATCH_FILES, 1]
);
assert!(sizes(Vec::new()).is_empty());
}
}
Mweb/src/util.rs
@@ -20,6 +20,91 @@ pub fn owned_window_listener<E: leptos::ev::EventDescriptor + 'static>(
on_cleanup(move || handle.remove());
}
/// A touch long press (500 ms without lifting or moving) that stands in for
/// a right click. Android fires `contextmenu` on a long press by itself;
/// iOS Safari does not, so the pointer events are watched directly.
///
/// One instance per element. Wire `down`, `up`, `mv` to `pointerdown`,
/// `pointerup` + `pointercancel` + `pointerleave`, and `pointermove`. The
/// element's `click` handler must call [`LongPress::swallow_click`] first:
/// the browser still sends the click of the finger lifting after the menu
/// opened, and that click would close the menu again.
#[derive(Clone, Copy)]
pub struct LongPress {
inner: StoredValue<LongPressState, LocalStorage>,
}
#[derive(Default)]
struct LongPressState {
timer: Option<gloo_timers::callback::Timeout>,
start: (i32, i32),
fired: bool,
}
impl LongPress {
pub const DELAY_MS: u32 = 500;
/// Finger travel that turns the press into a scroll.
const SLOP_PX: i32 = 10;
pub fn new() -> Self {
Self {
inner: StoredValue::new_local(LongPressState::default()),
}
}
/// `pointerdown`: arm the timer for touch pointers; `on_fire` gets the
/// client coordinates. Stops propagation so a nested element and its
/// container do not both arm.
pub fn down(&self, ev: &web_sys::PointerEvent, on_fire: impl Fn(i32, i32) + 'static) {
if ev.pointer_type() != "touch" {
return;
}
ev.stop_propagation();
let (x, y) = (ev.client_x(), ev.client_y());
let inner = self.inner;
let timer = gloo_timers::callback::Timeout::new(Self::DELAY_MS, move || {
inner.update_value(|st| {
st.timer = None;
st.fired = true;
});
on_fire(x, y);
});
self.inner.update_value(|st| {
st.timer = Some(timer);
st.start = (x, y);
st.fired = false;
});
}
/// `pointerup`, `pointercancel`, `pointerleave`: disarm.
pub fn up(&self) {
self.inner.update_value(|st| st.timer = None);
}
/// `pointermove`: disarm once the finger travels; that is a scroll.
pub fn mv(&self, ev: &web_sys::PointerEvent) {
self.inner.update_value(|st| {
if st.timer.is_none() {
return;
}
let (sx, sy) = st.start;
if (ev.client_x() - sx).abs() > Self::SLOP_PX
|| (ev.client_y() - sy).abs() > Self::SLOP_PX
{
st.timer = None;
}
});
}
/// True once, for the click that follows a fired press. The caller
/// stops that click instead of handling it.
pub fn swallow_click(&self) -> bool {
self.inner
.try_update_value(|st| std::mem::take(&mut st.fired))
.unwrap_or(false)
}
}
/// Same as [`owned_window_listener`], but registered in the capture phase,
/// so it runs before the bubble-phase window listeners (registration order
/// otherwise decides, and that depends on component creation order).
Mweb/src/views/admin.rs
@@ -119,6 +119,8 @@ pub fn SettingsView(
// Profile language: Some(None) = auto (follow the browser).
let (language, set_language) = signal(Option::<Option<String>>::None);
let (thumbnails, set_thumbnails) = signal(Option::<bool>::None);
// Default root: Some(None) = the folder overview.
let (default_root, set_default_root) = signal(Option::<Option<i64>>::None);
// Without `--cache` the server cannot make thumbnails, so hide the row
// rather than offer a setting that does nothing.
let thumbs_available = move || me.get().is_some_and(|m| m.thumbnails_available);
@@ -127,6 +129,7 @@ pub fn SettingsView(
set_single_click.set(Some(u.single_click_open));
set_thumbnails.set(Some(u.thumbnails));
set_language.set(Some(u.language.clone()));
set_default_root.set(Some(u.default_root_id));
}
});
// Saves both profile settings in one call.
@@ -137,14 +140,18 @@ pub fn SettingsView(
let Some(v) = single_click.get() else { return };
let Some(th) = thumbnails.get() else { return };
let Some(lang) = language.get() else { return };
let Some(root) = default_root.get() else {
return;
};
set_profile_busy.set(true);
let toast2 = toast;
let set_me2 = set_me;
let set_val = set_single_click;
let set_th = set_thumbnails;
let set_lang = set_language;
let set_root = set_default_root;
spawn_local(async move {
match api::update_profile(Some(v), Some(th), Some(lang.clone())).await {
match api::update_profile(Some(v), Some(th), Some(lang.clone()), Some(root)).await {
Ok(m) => {
show(toast2, i18n::t(i18n::k::PROFILE_SAVED).to_string());
// The response already carries the fresh /me (which also
@@ -153,6 +160,7 @@ pub fn SettingsView(
set_val.set(Some(v));
set_th.set(Some(th));
set_lang.set(Some(lang));
set_root.set(Some(root));
}
Err(e) => show(
toast2,
@@ -265,9 +273,10 @@ pub fn SettingsView(
</Show>
</div>
<Show when=move || tab.get() == SettingsTab::Profile>
{move || match (single_click.get(), language.get()) {
(Some(sc), Some(lang)) => {
{move || match (single_click.get(), language.get(), default_root.get()) {
(Some(sc), Some(lang), Some(root)) => {
let lang_val = lang.clone().unwrap_or_default();
let roots = me.get().map(|m| m.roots).unwrap_or_default();
view! {
<label class="setting-row">
<span>
@@ -330,6 +339,28 @@ pub fn SettingsView(
<option value="fr" selected=lang_val == "fr">{i18n::t(i18n::k::LANG_FR)}</option>
</select>
</div>
<div class="setting-row setting-row-select">
<span>
<span class="setting-label">{i18n::t(i18n::k::DEFAULT_ROOT_LABEL)}</span>
<span class="setting-desc">{i18n::t(i18n::k::DEFAULT_ROOT_DESC)}</span>
</span>
<select
on:change=move |ev: web_sys::Event| {
let Some(v) = select_value(&ev) else {
return;
};
set_default_root.set(Some(v.parse().ok()));
}
>
<option value="" selected=root.is_none()>{i18n::t(i18n::k::DEFAULT_ROOT_NONE)}</option>
{roots.iter().map(|r| {
let id = r.id;
view! {
<option value=id.to_string() selected=root == Some(id)>{r.name.clone()}</option>
}
}).collect::<Vec<_>>()}
</select>
</div>
<div class="modal-actions">
<button
class="btn btn-primary"
Mweb/src/views/browser.rs
@@ -17,7 +17,10 @@ use crate::i18n;
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::util::{FILE_PAGE, SortKey, SortSpec, ViewMode, format_date, format_size, page_footer};
use crate::uploads::{self, OnConflict};
use crate::util::{
FILE_PAGE, LongPress, SortKey, SortSpec, ViewMode, format_date, format_size, page_footer,
};
use crate::views::dialogs::{Dialog, Op};
use crate::views::file_view::{FileView, UnsupportedTarget};
@@ -414,6 +417,7 @@ pub fn Browser(
open
});
let drop_owner = ctx_owner.clone();
view! {
<div class="browser">
{move || {
@@ -428,6 +432,39 @@ pub fn Browser(
me,
root,
loc,
root.mode.is_writable().then(|| {
let (root_id, dir, owner) =
(root.id, loc_now.path.join("/"), drop_owner.clone());
Callback::new(move |ev: web_sys::DragEvent| {
// Read the drop here, not in the task below:
// the `DataTransfer` is dead once this
// handler returns (see `api::read_drop`).
let dropped = api::read_drop(&ev);
let names = dropped.names();
if names.is_empty() {
return;
}
// The job shows at once; walking a big
// dropped folder takes a while.
let label = upload_label(names.iter().map(String::as_str));
let job = uploads::prepare(root_id, dir.clone(), label);
let (dir, owner) = (dir.clone(), owner.clone());
spawn_local(async move {
let files = api::files_from_drop(dropped).await;
if files.is_empty() {
uploads::discard(job);
return;
}
uploads::set_label(
job,
upload_label(files.iter().map(|(p, _)| p.as_str())),
);
upload_prepared(
job, root_id, dir, files, toast, dialog, set_dialog, owner,
);
});
})
}),
sorted,
shown,
view_mode,
@@ -453,6 +490,7 @@ pub fn Browser(
selected=selected
refresh=fetch
toast=toast
dialog=dialog
set_dialog=set_dialog
open_file=open_file
owner=ctx_owner
@@ -503,6 +541,8 @@ fn file_browser(
me: ReadSignal<Option<Me>>,
root: &RootInfo,
loc: ReadSignal<Location>,
// Upload the files of a drop into this folder; `None` when read-only.
drop_upload: Option<Callback<web_sys::DragEvent>>,
list_state: Memo<ListState>,
shown: RwSignal<usize>,
view_mode: ReadSignal<ViewMode>,
@@ -686,17 +726,73 @@ fn file_browser(
</div>
};
let area_press = LongPress::new();
// Drag-and-drop upload. `dragenter`/`dragleave` also fire for every
// child, so a depth counter tells "left the area" from "moved inside".
let (drag_depth, set_drag_depth) = signal(0i32);
let drag_files =
move |ev: &web_sys::DragEvent| drop_upload.is_some() && api::drag_has_files(ev);
let entries_area = view! {
<div
class="entries-area"
class=("drop-target", move || drag_depth.get() > 0)
on:dragenter=move |ev: web_sys::DragEvent| {
if drag_files(&ev) {
ev.prevent_default();
set_drag_depth.update(|d| *d += 1);
}
}
on:dragover=move |ev: web_sys::DragEvent| {
if drag_files(&ev) {
// Required, or the browser refuses the drop.
ev.prevent_default();
if let Some(dt) = ev.data_transfer() {
dt.set_drop_effect("copy");
}
}
}
on:dragleave=move |_| set_drag_depth.update(|d| *d = (*d - 1).max(0))
on:drop=move |ev: web_sys::DragEvent| {
set_drag_depth.set(0);
let Some(cb) = drop_upload else { return };
if !api::drag_has_files(&ev) {
return;
}
ev.prevent_default();
cb.run(ev);
}
// Clicking empty space clears the selection.
on:click=move |_| set_selected.set(Vec::new())
on:click=move |ev: MouseEvent| {
if area_press.swallow_click() {
// Else the window listener closes the menu just opened.
ev.stop_propagation();
return;
}
set_selected.set(Vec::new())
}
on:contextmenu=move |ev: MouseEvent| {
ev.prevent_default();
set_selected.set(Vec::new());
set_ctx.set(Some((ev.client_x(), ev.client_y())));
}
// Touch long press on empty space: the same menu as a right click.
on:pointerdown=move |ev: web_sys::PointerEvent| {
area_press.down(&ev, move |x, y| {
set_selected.set(Vec::new());
set_ctx.set(Some((x, y)));
});
}
on:pointerup=move |_| area_press.up()
on:pointercancel=move |_| area_press.up()
on:pointerleave=move |_| area_press.up()
on:pointermove=move |ev: web_sys::PointerEvent| area_press.mv(&ev)
>
<Show when=move || drag_depth.get().is_positive()>
<div class="drop-hint">
<Icon name=IconName::Upload class="ic-tab".to_string()/>
<span>{i18n::tr(i18n::k::UPLOAD_DROP_HINT)}</span>
</div>
</Show>
{move || {
match list_state.get() {
ListState::Loading => view! {
@@ -786,14 +882,34 @@ fn ctx_menu_handler(
move |ev: MouseEvent| {
ev.prevent_default();
ev.stop_propagation();
let entry = &all.entries[idx];
if !selected.with_untracked(|v| v.iter().any(|s| s.name == entry.name)) {
set_selected.set(vec![entry.clone()]);
}
set_ctx.set(Some((ev.client_x(), ev.client_y())));
open_entry_ctx(
&all,
idx,
set_ctx,
selected,
set_selected,
(ev.client_x(), ev.client_y()),
);
}
}
/// Open the context menu for one entry at `at`. Shared by the right click
/// and the touch long press.
fn open_entry_ctx(
all: &Listing,
idx: usize,
set_ctx: WriteSignal<Option<CtxMenu>>,
selected: ReadSignal<Vec<Entry>>,
set_selected: WriteSignal<Vec<Entry>>,
at: CtxMenu,
) {
let entry = &all.entries[idx];
if !selected.with_untracked(|v| v.iter().any(|s| s.name == entry.name)) {
set_selected.set(vec![entry.clone()]);
}
set_ctx.set(Some(at));
}
/// Items per row in the grid.
///
/// Read from the DOM on every call, not cached: the grid is CSS
@@ -1167,12 +1283,30 @@ fn entries_view(
let all_keys = all.clone();
let item_name = name.clone();
let selected_now = move || sel_names.with(|s| s.contains(&item_name));
let press = LongPress::new();
let all_press = all.clone();
view! {
<div
class=item
class:selected=selected_now
tabindex=0
on:click=on_click
on:pointerdown=move |ev: web_sys::PointerEvent| {
let all = all_press.clone();
press.down(&ev, move |x, y| {
open_entry_ctx(&all, i, set_ctx, selected, set_selected, (x, y));
});
}
on:pointerup=move |_| press.up()
on:pointercancel=move |_| press.up()
on:pointerleave=move |_| press.up()
on:pointermove=move |ev: web_sys::PointerEvent| press.mv(&ev)
on:click=move |ev: MouseEvent| {
if press.swallow_click() {
ev.stop_propagation();
return;
}
on_click(ev);
}
on:dblclick=move |ev: MouseEvent| if plain_dblclick(&ev) { open_cb.run(()) }
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
@@ -1246,6 +1380,7 @@ fn menu_items(
open_file: WriteSignal<Option<FileView>>,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Vec<MenuItem> {
@@ -1287,13 +1422,31 @@ fn menu_items(
IconName::Upload,
i18n::t(i18n::k::UPLOAD_FILES),
is_rw,
action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
action_upload(
true,
false,
root_id,
loc,
toast,
dialog,
set_dialog,
owner.clone(),
),
),
MenuItem::rw(
IconName::UploadFolder,
i18n::t(i18n::k::UPLOAD_FOLDER),
is_rw,
action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
action_upload(
true,
true,
root_id,
loc,
toast,
dialog,
set_dialog,
owner.clone(),
),
),
];
// Sharing and search are signed-in features; a share visitor has
@@ -1382,6 +1535,7 @@ fn menu_items(
loc,
refresh,
toast,
dialog,
set_dialog,
owner.clone(),
),
@@ -1399,6 +1553,7 @@ fn menu_items(
loc,
refresh,
toast,
dialog,
set_dialog,
owner.clone(),
),
@@ -1446,6 +1601,7 @@ fn CtxMenuView(
selected: ReadSignal<Vec<Entry>>,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
open_file: WriteSignal<Option<FileView>>,
owner: Owner,
@@ -1504,7 +1660,7 @@ fn CtxMenuView(
};
};
let items = selected.with(|sel| {
menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone())
});
view! {
@@ -1597,6 +1753,7 @@ pub fn SelectionActions(
open_file: WriteSignal<Option<FileView>>,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
topbar_ref: NodeRef<leptos::html::Header>,
@@ -1689,7 +1846,7 @@ pub fn SelectionActions(
};
}
let items = selected.with(|sel| {
menu_items(sel, me, loc, open_file, refresh, toast, set_dialog, owner.clone())
menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone())
});
let fit = visible_n.get();
let n = if fit >= items.len() {
@@ -1801,6 +1958,7 @@ type OpFuture = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), ap
/// `names` is what to list in the dialog when the server does not say. Upload
/// reports the exact set it skipped; the single-item operations know theirs
/// before they ask.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn run_with_overwrite_retry(
op: impl Fn(bool) -> OpFuture + Clone + Send + Sync + 'static,
conflict_title: &'static str,
@@ -1808,6 +1966,7 @@ fn run_with_overwrite_retry(
ok_msg: &'static str,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
) {
fn done(ok_msg: &'static str, refresh: Callback<()>, toast: ToastMsg) {
@@ -1829,6 +1988,7 @@ fn run_with_overwrite_retry(
show_error(toast, e.to_string());
return;
};
crate::views::dialogs::claim_async_dialog(dialog).await;
set_dialog.set(Some(Dialog::Conflict {
title: conflict_title.to_string(),
files,
@@ -2016,41 +2176,150 @@ fn action_delete(
})
}
/// Pick files (or a folder) and upload them into the current directory.
///
/// Flow: pick → ask the server which targets exist → if any, the conflict
/// dialog (overwrite / skip / cancel, "apply to all") → start a job. The
/// job runs in `uploads`; its progress shows in the panel, not in a toast.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn action_upload(
multiple: bool,
directory: bool,
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let dir = loc.path.join("/");
let dir1 = dir.clone();
let dir = loc.get().path.join("/");
let owner = owner.clone();
api::pick_files(multiple, directory, move |files| {
let n = files.len();
let dir = dir1.clone();
show(toast, i18n::t_fmt(i18n::k::UPLOADING, &n.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 })
},
i18n::t(i18n::k::SOME_FILES_EXIST),
// Unused: an upload conflict always names the skipped files.
Vec::new(),
i18n::t(i18n::k::UPLOAD_COMPLETE),
refresh,
let label = upload_label(files.iter().map(|(p, _)| p.as_str()));
let job = uploads::prepare(root_id, dir.clone(), label);
upload_prepared(
job,
root_id,
dir.clone(),
files,
toast,
dialog,
set_dialog,
owner.clone(),
);
});
})
}
/// The job label: the one name for a single item, the top folder when every
/// path carries a slash (a folder pick), else "N files". Takes relative
/// paths after a pick, top-level names after a drop.
fn upload_label<'a>(names: impl Iterator<Item = &'a str>) -> String {
let names: Vec<&str> = names.collect();
match names[..] {
[] => String::new(),
[one] => one.to_string(),
ref many if many.iter().all(|p| p.contains('/')) => {
many[0].split('/').next().unwrap_or_default().to_string()
}
ref many => i18n::t_fmt(i18n::k::UPLOAD_N_FILES, &many.len().to_string()),
}
}
/// Upload picked or dropped files into `dir`: existence check, conflict
/// dialog, transfer. The job already shows in the panel as "preparing".
#[allow(clippy::too_many_arguments)] // explicit signal props
fn upload_prepared(
job: u64,
root_id: i64,
dir: String,
files: Vec<(String, web_sys::File)>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) {
spawn_local(async move {
let paths: Vec<String> = files.iter().map(|(rel, _)| rel.clone()).collect();
let existing = match api::check_exists(root_id, &dir, paths).await {
Ok(e) => e,
Err(e) => {
uploads::discard(job);
show_error(
toast,
i18n::t_fmt(i18n::k::UPLOAD_CHECK_FAILED, &e.to_string()),
);
return;
}
};
// A folder where a file would go can never be written.
// Report it in the job and leave it out of the upload.
let dirs: std::collections::HashSet<String> = existing
.iter()
.filter(|e| e.is_dir)
.map(|e| e.path.clone())
.collect();
let conflicts: Vec<String> = existing
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.path.clone())
.collect();
let errors: Vec<(String, String)> = dirs
.iter()
.map(|p| (p.clone(), i18n::t(i18n::k::ERR_FOLDER_EXISTS).to_string()))
.collect();
let files: Vec<(String, web_sys::File)> = files
.into_iter()
.filter(|(rel, _)| !dirs.contains(rel.as_str()))
.collect();
let start = move |on_conflict: OnConflict, decided: Vec<String>, overwrite: bool| {
uploads::begin(
job,
files.clone(),
errors.clone(),
on_conflict,
decided,
overwrite,
);
};
if conflicts.is_empty() {
start(OnConflict::Ask, Vec::new(), false);
return;
}
// Callbacks made inside a short-lived async scope die with
// it; the browser's owner keeps this one alive.
let on_decide = owner.with(|| {
let conflicts = conflicts.clone();
let start = std::sync::Arc::new(std::sync::Mutex::new(Some(start)));
Callback::new(move |answer: Option<(bool, bool)>| {
let Some(start) = start.lock().unwrap().take() else {
return;
};
let Some((overwrite, apply_all)) = answer else {
uploads::discard(job);
return;
};
// The answer covers the listed files. "Apply to all" extends
// it to files that appear during the transfer.
let rule = match (apply_all, overwrite) {
(false, _) => OnConflict::Ask,
(true, true) => OnConflict::Overwrite,
(true, false) => OnConflict::Skip,
};
start(rule, conflicts.clone(), overwrite);
})
});
// The job sits in the panel as "preparing" while it waits.
crate::views::dialogs::claim_async_dialog(dialog).await;
set_dialog.set(Some(Dialog::UploadConflict {
files: conflicts,
on_decide,
}));
});
}
/// Move or copy the entries to a folder chosen in the picker. Several are
/// processed one by one; name collisions are collected and asked about once,
/// and the overwrite retry re-runs only the items that collided.
@@ -2063,6 +2332,7 @@ fn action_move_copy(
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
@@ -2140,6 +2410,7 @@ fn action_move_copy(
},
refresh,
toast,
dialog,
set_dialog,
);
})
Mweb/src/views/dialogs.rs
@@ -74,6 +74,14 @@ pub enum Dialog {
files: Vec<String>,
on_submit: Callback<()>,
},
/// Upload pre-check found existing files. Overwrite or skip them, and
/// optionally make that the rule for files that appear mid-transfer.
UploadConflict {
files: Vec<String>,
/// `(overwrite, apply_to_all)`.
/// `None` when the user cancelled (button, Escape, backdrop).
on_decide: Callback<Option<(bool, bool)>>,
},
/// Folder picker: the move/copy destination, or where a search starts.
Picker {
title: String,
@@ -125,6 +133,17 @@ fn dir_segs(dir: &str) -> Vec<String> {
.collect()
}
/// Wait until the shell's one dialog slot is free. A background task
/// (upload or move conflict) must not replace a dialog that is showing,
/// whether another task or the user opened it.
///
/// ponytail: 200 ms poll, wake channel if latency matters.
pub async fn claim_async_dialog(dialog: ReadSignal<Option<Dialog>>) {
while dialog.get_untracked().is_some() {
gloo_timers::future::sleep(std::time::Duration::from_millis(200)).await;
}
}
#[component]
pub fn DialogView(
dialog: ReadSignal<Option<Dialog>>,
@@ -199,6 +218,14 @@ pub fn DialogView(
.into_view()
.into_any()
}
Dialog::UploadConflict { files, on_decide } => {
let (files, on_decide) = (files.clone(), *on_decide);
view! {
<UploadConflictDialog files=files on_decide=on_decide on_close=close/>
}
.into_view()
.into_any()
}
Dialog::Conflict {
title,
files,
@@ -445,6 +472,21 @@ fn ConfirmDialog(
// Conflict (overwrite warning)
// ---------------------------------------------------------------------------
/// The list of colliding files shown by both conflict dialogs. Long lists
/// are cut off with an "and N more" line.
fn conflict_list(files: &[String]) -> impl IntoView + use<> {
let shown: Vec<String> = files.iter().take(20).cloned().collect();
let extra = files.len() - shown.len();
view! {
<ul class="conflict-list">
{shown.into_iter().map(|f| view! { <li>{f}</li> }).collect::<Vec<_>>()}
<Show when=move || { extra > 0 }>
<li class="muted">{move || i18n::t_fmt(i18n::k::AND_MORE, &extra.to_string())}</li>
</Show>
</ul>
}
}
#[component]
fn ConflictDialog(
title: String,
@@ -452,29 +494,13 @@ fn ConflictDialog(
on_submit: Callback<()>,
on_close: Callback<()>,
) -> impl IntoView {
let shown: Vec<String> = files.iter().take(20).cloned().collect();
let extra = files.len().saturating_sub(shown.len());
view! {
<Modal class="card" on_close=on_close>
<h2 class="modal-title">{title}</h2>
<p class="modal-message">
{i18n::tr(i18n::k::OVERWRITE_QUESTION)}
</p>
<ul class="conflict-list">
{shown.iter().map(|f| {
let f = f.clone();
view! { <li>{f}</li> }
}).collect::<Vec<_>>()}
{move || {
if extra > 0 {
view! { <li class="muted">{move || i18n::t_fmt(i18n::k::AND_MORE, &extra.to_string())}</li> }
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
</ul>
{conflict_list(&files)}
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
{i18n::tr(i18n::k::CANCEL)}
@@ -493,6 +519,59 @@ fn ConflictDialog(
}
}
/// The upload pre-check dialog: Overwrite, Skip, Cancel, and "apply to all"
/// (on by default) for files that appear during the transfer.
#[component]
fn UploadConflictDialog(
files: Vec<String>,
on_decide: Callback<Option<(bool, bool)>>,
on_close: Callback<()>,
) -> impl IntoView {
let (apply_all, set_apply_all) = signal(true);
// The <dialog> close event fires after a decision too; answer once.
let answered = StoredValue::new(false);
let decide = move |overwrite: bool| {
answered.set_value(true);
on_decide.run(Some((overwrite, apply_all.get_untracked())));
on_close.run(());
};
let on_close = Callback::new(move |()| {
if !answered.get_value() {
answered.set_value(true);
on_decide.run(None);
}
on_close.run(());
});
view! {
<Modal class="card" on_close=on_close>
<h2 class="modal-title">{i18n::tr(i18n::k::SOME_FILES_EXIST)}</h2>
<p class="modal-message">
{i18n::tr(i18n::k::UPLOAD_CONFLICT_QUESTION)}
</p>
{conflict_list(&files)}
<label class="upload-conflict-all">
<input
type="checkbox"
checked=move || apply_all.get()
on:change=move |ev| set_apply_all.set(event_target_checked(&ev))
/>
{i18n::tr(i18n::k::UPLOAD_APPLY_ALL)}
</label>
<div class="modal-actions">
<button class="btn" on:click=move |_| on_close.run(())>
{i18n::tr(i18n::k::CANCEL)}
</button>
<button class="btn" on:click=move |_| decide(false)>
{i18n::tr(i18n::k::UPLOAD_SKIP)}
</button>
<button class="btn btn-danger" on:click=move |_| decide(true)>
{i18n::tr(i18n::k::OVERWRITE)}
</button>
</div>
</Modal>
}
}
// ---------------------------------------------------------------------------
// Picker (folder chooser)
// ---------------------------------------------------------------------------
Mweb/src/views/mod.rs
@@ -8,4 +8,5 @@ pub mod setup;
pub mod share_page;
pub mod shares;
pub mod shell;
pub mod uploads;
pub mod workspace;
Mweb/src/views/shell.rs
@@ -13,6 +13,7 @@ use crate::views::admin::{SettingsView, UsersView};
use crate::views::browser::{Browser, effective_root};
use crate::views::search::SearchView;
use crate::views::shares::SharesView;
use crate::views::uploads::UploadsView;
use crate::views::workspace::FileWorkspace;
/// Whether the signed-in user has admin rights.
@@ -22,6 +23,22 @@ fn is_admin_of(me: &Option<Me>) -> bool {
.is_some_and(|u| u.is_admin)
}
/// The root to open on page load, or None to stay where the URL points.
///
/// Rules, each one closing a loop or overriding case:
/// 1. Only once per page load (`applied`), so a later `/me` refresh never
/// navigates again.
/// 2. Only from the bare `#/` location. A deep link wins.
/// 3. Only when the default root is in the root list. The server drops a
/// stale id already; this is the second guard.
pub fn pick_default_root(me: &Me, loc: &Location, applied: bool) -> Option<i64> {
if applied || *loc != Location::root() {
return None;
}
let id = me.user.as_ref()?.default_root_id?;
me.roots.iter().any(|r| r.id == id).then_some(id)
}
/// Re-fetch this session's `/me`. For things that can change the session
/// outside of the current view: the sidebar's refresh button, or saving a
/// user that is the current user.
@@ -101,6 +118,26 @@ pub fn ShellView(
});
}
// Open the profile's default root once per page load. The guards keep
// this from looping: a stale default that 404s refreshes `/me`, which
// must not trigger a second redirect.
{
let applied = StoredValue::new(false);
Effect::new(move |_| {
let Some(m) = me.get() else {
return;
};
let target = loc.with_untracked(|l| pick_default_root(&m, l, applied.get_value()));
applied.set_value(true);
if let Some(id) = target {
navigate(&Location {
root_id: Some(id),
..Location::root()
});
}
});
}
let on_logout = move |_| {
spawn_local(async move {
let _ = api::logout().await;
@@ -275,6 +312,14 @@ pub fn ShellView(
<span class="nav-tab-name">{i18n::tr(i18n::k::USERS)}</span>
</button>
</Show>
<button
class="nav-tab"
class:active=move || section.get() == Section::Uploads
on:click=move |_| open_section(Section::Uploads)
>
<Icon name=IconName::Upload class="ic-tab".to_string()/>
<span class="nav-tab-name">{i18n::tr(i18n::k::UPLOADS)}</span>
</button>
<button
class="nav-tab"
class:active=move || section.get() == Section::Settings
@@ -363,6 +408,7 @@ pub fn ShellView(
}
.into_view()
.into_any(),
Section::Uploads => view! { <UploadsView/> }.into_view().into_any(),
// Not an admin anymore (e.g. permissions changed):
// fall back to the files view.
_ => view! {
@@ -390,3 +436,55 @@ pub fn ShellView(
</div>
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::{Mode, RootInfo, UserInfo};
fn me(default: Option<i64>) -> Me {
Me {
first_boot: false,
user: Some(UserInfo {
id: 1,
name: "u".into(),
is_admin: false,
single_click_open: false,
thumbnails: false,
language: None,
default_root_id: default,
}),
roots: vec![RootInfo {
id: 7,
name: "r".into(),
path: "r".into(),
mode: Mode::Rw,
}],
allow_writable_shares: false,
thumbnails_available: false,
public_url: None,
}
}
#[test]
fn default_root_rules() {
let home = Location::root();
assert_eq!(pick_default_root(&me(Some(7)), &home, false), Some(7));
// Once per page load.
assert_eq!(pick_default_root(&me(Some(7)), &home, true), None);
// A deep link wins.
let deep = Location {
root_id: Some(7),
..Location::root()
};
assert_eq!(pick_default_root(&me(Some(7)), &deep, false), None);
let section = Location {
section: Section::Shares,
..Location::root()
};
assert_eq!(pick_default_root(&me(Some(7)), §ion, false), None);
// Unknown root, or none set.
assert_eq!(pick_default_root(&me(Some(8)), &home, false), None);
assert_eq!(pick_default_root(&me(None), &home, false), None);
}
}
Aweb/src/views/uploads.rs
@@ -0,0 +1,313 @@
//! Upload UI: the bottom-right overlay for active jobs, and the `#/uploads`
//! view that lists every job of this session as a log.
use leptos::prelude::*;
use crate::components::icon::Icon;
use crate::i18n;
use crate::icons::IconName;
use crate::uploads::{self, Job, JobState, OnConflict, format_speed, speeds};
use crate::util::format_size;
/// Bottom-right overlay: active jobs, and finished ones for a few seconds.
/// Hidden when it has nothing to show. Collapses to its header line.
#[component]
pub fn UploadPanel() -> impl IntoView {
let jobs = uploads::jobs();
let collapsed = uploads::collapsed();
let shown = Memo::new(move |_| {
jobs.with(|list| {
list.iter()
.filter(|j| !j.hidden)
.map(|j| j.id)
.collect::<Vec<u64>>()
})
});
let summary = move || {
jobs.with(|list| {
let active: Vec<&Job> = list.iter().filter(|j| j.state.is_active()).collect();
if active.is_empty() {
return i18n::t(i18n::k::UPLOAD_COMPLETE).to_string();
}
let total: f64 = active.iter().map(|j| j.total).sum();
let sent: f64 = active.iter().map(|j| j.sent()).sum();
let pct = if total > 0.0 {
sent / total * 100.0
} else {
0.0
};
format!(
"{} · {pct:.0}%",
i18n::t_fmt(i18n::k::UPLOAD_ACTIVE_N, &active.len().to_string())
)
})
};
view! {
<Show when=move || !shown.get().is_empty()>
<div class="upload-panel" class:collapsed=move || collapsed.get()>
<div class="upload-panel-head">
<Icon name=IconName::Upload class="ic-btn".to_string()/>
<span class="upload-panel-title">{i18n::tr(i18n::k::UPLOADS)}</span>
<span class="muted upload-panel-summary">{summary}</span>
<button
class="icon-btn icon-btn-sm"
title=move || if collapsed.get() {
i18n::t(i18n::k::UPLOAD_EXPAND).to_string()
} else {
i18n::t(i18n::k::UPLOAD_COLLAPSE).to_string()
}
on:click=move |_| collapsed.update(|c| *c = !*c)
>
{move || view! {
<Icon
name=if collapsed.get() { IconName::ExpandLess } else { IconName::ExpandMore }
class="ic-btn".to_string()
/>
}}
</button>
</div>
<Show when=move || !collapsed.get()>
<div class="upload-panel-list">
<For each=move || shown.get() key=|id| *id let:id>
<JobRow id=id in_log=false/>
</For>
</div>
</Show>
</div>
</Show>
}
}
/// The `#/uploads` section: every job of this session, clearable.
#[component]
pub fn UploadsView() -> impl IntoView {
let jobs = uploads::jobs();
let ids =
Memo::new(move |_| jobs.with(|list| list.iter().rev().map(|j| j.id).collect::<Vec<u64>>()));
let any_finished = move || jobs.with(|list| list.iter().any(|j| j.is_finished()));
view! {
<div class="uploads-view">
<div class="users-header">
<h2 class="admin-section-title">{i18n::tr(i18n::k::UPLOADS)}</h2>
<Show when=any_finished>
<button class="btn" on:click=move |_| uploads::clear_finished()>
{i18n::tr(i18n::k::UPLOAD_CLEAR_FINISHED)}
</button>
</Show>
</div>
<Show
when=move || !ids.get().is_empty()
fallback=move || view! {
<div class="empty-state">
<Icon name=IconName::Upload class="empty-glyph".to_string()/>
<h3>{i18n::tr(i18n::k::UPLOADS_EMPTY)}</h3>
</div>
}
>
<div class="upload-log">
<For each=move || ids.get() key=|id| *id let:id>
<JobRow id=id in_log=true/>
</For>
</div>
</Show>
</div>
}
}
/// One job: label, progress bar, state, controls, and the speed graph when
/// expanded. `in_log` adds the clear button for finished jobs.
#[component]
fn JobRow(id: u64, in_log: bool) -> impl IntoView {
let jobs = uploads::jobs();
// The row's own slice of the list. Every progress event updates the
// list; this keeps a row from re-rendering for another job's bytes.
let job = Memo::new(move |_| jobs.with(|list| list.iter().find(|j| j.id == id).cloned()));
let with =
move |f: &dyn Fn(&Job) -> String| job.with(|j| j.as_ref().map(f).unwrap_or_default());
let state_text = move || {
job.with(|j| {
let Some(j) = j else { return String::new() };
match &j.state {
JobState::Preparing => i18n::t(i18n::k::UPLOAD_PREPARING).to_string(),
JobState::Running => {
let mut s = format!(
"{} / {}",
format_size(j.sent() as u64),
format_size(j.total as u64)
);
if j.file_count > 1 {
let n = (j.current + 1).min(j.file_count);
s.push_str(&format!(" · {}/{}", n, j.file_count));
}
s.push_str(&format!(
" · {} · {}",
format_speed(j.speed()),
j.current_name
));
s
}
JobState::Paused => i18n::t(i18n::k::UPLOAD_PAUSED).to_string(),
JobState::Conflict(_) => i18n::t(i18n::k::UPLOAD_WAITING).to_string(),
JobState::Done => {
let mut s = format!(
"{} · {}",
i18n::t(i18n::k::UPLOAD_COMPLETE),
format_size(j.total as u64)
);
if !j.skipped.is_empty() {
s.push_str(" · ");
s.push_str(&i18n::t_fmt(
i18n::k::UPLOAD_SKIPPED_N,
&j.skipped.len().to_string(),
));
}
if !j.errors.is_empty() {
s.push_str(" · ");
s.push_str(&i18n::t_fmt(
i18n::k::UPLOAD_ERRORS_N,
&j.errors.len().to_string(),
));
}
s
}
JobState::Failed(msg) => format!("{}: {msg}", i18n::t(i18n::k::UPLOAD_FAILED)),
JobState::Aborted => i18n::t(i18n::k::UPLOAD_ABORTED).to_string(),
}
})
};
let is = move |f: fn(&JobState) -> bool| job.with(|j| j.as_ref().is_some_and(|j| f(&j.state)));
let failed = move || is(|s| matches!(s, JobState::Failed(_) | JobState::Aborted));
let has_errors = move || job.with(|j| j.as_ref().is_some_and(|j| !j.errors.is_empty()));
let expanded = move || job.with(|j| j.as_ref().is_some_and(|j| j.expanded));
view! {
<div class="upload-job" class:failed=failed class:done=move || is(|s| *s == JobState::Done)>
<div class="upload-job-head">
<span class="upload-job-label" title=move || with(&|j| format!("{}/{}", j.root_id, j.dir))>
{move || with(&|j| j.label.clone())}
</span>
<span class="upload-job-pct">{move || with(&|j| format!("{:.0}%", j.percent()))}</span>
<Show when=move || is(|s| *s == JobState::Running)>
<button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_PAUSE)} on:click=move |_| uploads::pause(id)>
<Icon name=IconName::Pause class="ic-btn".to_string()/>
</button>
</Show>
<Show when=move || is(|s| *s == JobState::Paused)>
<button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_RESUME)} on:click=move |_| uploads::resume(id)>
<Icon name=IconName::Play class="ic-btn".to_string()/>
</button>
</Show>
<Show when=move || is(JobState::is_active)>
<button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_ABORT)} on:click=move |_| uploads::abort(id)>
<Icon name=IconName::Stop class="ic-btn".to_string()/>
</button>
</Show>
<Show when=move || in_log && !is(JobState::is_active)>
<button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_CLEAR)} on:click=move |_| uploads::clear(id)>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
</Show>
<button
class="icon-btn icon-btn-sm"
title={i18n::tr(i18n::k::UPLOAD_SHOW_GRAPH)}
on:click=move |_| uploads::toggle_expanded(id)
>
{move || view! {
<Icon
name=if expanded() { IconName::ExpandLess } else { IconName::ExpandMore }
class="ic-btn".to_string()
/>
}}
</button>
</div>
<div class="upload-bar" class:indeterminate=move || is(|s| *s == JobState::Preparing)>
<div class="upload-bar-fill" style:width=move || with(&|j| format!("{:.1}%", j.percent()))></div>
</div>
<div class="upload-job-state muted">{state_text}</div>
<Show when=move || is(|s| matches!(s, JobState::Conflict(_)))>
<ConflictPrompt id=id job=job/>
</Show>
<Show when=expanded>
<SpeedGraph job=job/>
<Show when=has_errors>
<ul class="upload-errors">
{move || job.with(|j| j.as_ref().map(|j| j.errors.iter().map(|(rel, msg)| {
view! { <li><span class="upload-err-file">{rel.clone()}</span>" — "{msg.clone()}</li> }
}).collect::<Vec<_>>()))}
</ul>
</Show>
</Show>
</div>
}
}
/// Inline prompt for a file that appeared during the transfer.
#[component]
fn ConflictPrompt(id: u64, job: Memo<Option<Job>>) -> impl IntoView {
let (apply_all, set_apply_all) = signal(true);
let file = move || {
job.with(|j| match j.as_ref().map(|j| &j.state) {
Some(JobState::Conflict(rel)) => rel.clone(),
_ => String::new(),
})
};
view! {
<div class="upload-conflict">
<span>{move || i18n::t_fmt(i18n::k::UPLOAD_EXISTS_ASK, &file())}</span>
<label class="upload-conflict-all">
<input
type="checkbox"
checked=move || apply_all.get()
on:change=move |ev: web_sys::Event| {
set_apply_all.set(event_target_checked(&ev));
}
/>
{i18n::tr(i18n::k::UPLOAD_APPLY_ALL)}
</label>
<div class="upload-conflict-actions">
<button class="btn" on:click=move |_| uploads::decide(id, OnConflict::Skip, apply_all.get())>
{i18n::tr(i18n::k::UPLOAD_SKIP)}
</button>
<button class="btn btn-danger" on:click=move |_| uploads::decide(id, OnConflict::Overwrite, apply_all.get())>
{i18n::tr(i18n::k::OVERWRITE)}
</button>
</div>
</div>
}
}
/// Transfer speed over the kept samples as one SVG polyline, auto-scaled.
#[component]
fn SpeedGraph(job: Memo<Option<Job>>) -> impl IntoView {
const W: f64 = 300.0;
const H: f64 = 60.0;
let points = move || {
job.with(|j| {
let Some(j) = j else {
return (String::new(), 0.0);
};
let speeds = speeds(&j.samples.iter().copied().collect::<Vec<_>>());
if speeds.is_empty() {
return (String::new(), 0.0);
}
let max = speeds.iter().cloned().fold(0.0, f64::max).max(1.0);
let n = speeds.len().max(2) as f64 - 1.0;
let pts = speeds
.iter()
.enumerate()
.map(|(i, s)| format!("{:.1},{:.1}", i as f64 / n * W, H - s / max * (H - 4.0)))
.collect::<Vec<_>>()
.join(" ");
(pts, max)
})
};
view! {
<div class="upload-graph">
<span class="upload-graph-max muted">{move || format_speed(points().1)}</span>
<svg viewBox=format!("0 0 {W} {H}") preserveAspectRatio="none" aria-hidden="true">
<polyline points=move || points().0 fill="none" stroke="currentColor" stroke-width="1.5"/>
</svg>
</div>
}
}
Mweb/src/views/workspace.rs
@@ -17,10 +17,12 @@ use crate::api::{Entry, Me};
use crate::components::logo::Logo;
use crate::components::toast::{ToastMsg, ToastView};
use crate::router::Location;
use crate::uploads;
use crate::util::owned_window_listener;
use crate::views::browser::SelectionActions;
use crate::views::dialogs::{Dialog, DialogView};
use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
use crate::views::uploads::UploadPanel;
/// The shared state and behavior around the full-page file view.
///
@@ -104,6 +106,35 @@ impl FileWorkspace {
set_selected.set(Vec::new());
});
// A finished upload re-lists the folder it went into, if that is
// the one on screen.
// The closure captures this page's signals: it must go when the
// page does, or a job finishing later reads disposed signals.
let hook = uploads::set_on_done(move |root_id, dir| {
let here = loc.with_untracked(|l| {
me.with_untracked(|m| {
m.as_ref()
.and_then(|m| crate::views::browser::effective_root(&m.roots, l))
.is_some_and(|r| r.id == root_id)
&& l.path.join("/") == dir
})
});
if here && let Some(f) = browser_refresh.get_untracked() {
f.run(());
}
});
on_cleanup(move || uploads::clear_on_done(hook));
// Leaving the page kills every running upload: let the browser ask.
owned_window_listener(
leptos::ev::beforeunload,
move |ev: web_sys::BeforeUnloadEvent| {
if uploads::any_active() {
ev.prevent_default();
ev.set_return_value("");
}
},
);
let close_file = {
let ct = close_target.clone();
Callback::new(move |_| {
@@ -171,10 +202,27 @@ impl FileWorkspace {
pub fn topbar_left(&self) -> impl IntoView + 'static + use<> {
let file_view = self.file_view;
let dirty = self.dirty;
let me = self.me;
// The home link opens the profile's default root, else the overview.
// A share page's synthetic `me` has no default, so it stays `#/`.
let home = move || {
let root = me.with(|m| {
m.as_ref()
.and_then(|m| m.user.as_ref())
.and_then(|u| u.default_root_id)
});
match root {
Some(id) => crate::router::location_to_hash(&Location {
root_id: Some(id),
..Location::root()
}),
None => "#/".to_string(),
}
};
view! {
{move || match file_view.get() {
Some(ref v) => view! {
<a class="brand brand-icon" href="#/">
<a class="brand brand-icon" href=home>
<Logo />
</a>
<FileViewTitle view=v.clone() dirty=dirty />
@@ -182,7 +230,7 @@ impl FileWorkspace {
.into_view()
.into_any(),
None => view! {
<a class="brand" href="#/">
<a class="brand" href=home>
<Logo />
<span class="brand-name">"filebrowser-ng"</span>
</a>
@@ -210,6 +258,7 @@ impl FileWorkspace {
let open_file = self.open_file;
let browser_refresh = self.browser_refresh;
let toast = self.toast;
let dialog = self.dialog;
let set_dialog = self.set_dialog;
let owner = self.owner.clone();
let topbar_ref = self.topbar_ref;
@@ -242,6 +291,7 @@ impl FileWorkspace {
open_file=open_file
refresh=refresh
toast=toast
dialog=dialog
set_dialog=set_dialog
owner=owner.clone()
topbar_ref=topbar_ref
@@ -302,6 +352,7 @@ impl FileWorkspace {
let set_dialog = self.set_dialog;
view! {
<ToastView toast=toast />
<UploadPanel />
<DialogView dialog=dialog set_dialog=set_dialog />
}
}