Close five authorization holes, type the wire enums, trim the frontend
Security fixes, each verified by reproducing the exploit first:
- A share token was accepted as the share creator's identity, so
`GET /api/shares?share=<any token>` returned every share row that user
owned, tokens included, and DELETE could remove them. Share management
and admin routes now take a new `SessionUser` extractor that rejects
requests carrying a share token instead of falling back to the cookie.
`AuthUser` no longer fabricates a `User` at all: the field had no readers
left once the routes moved, so the false identity is gone from the type
rather than from three call sites. `/api/files/*` still accepts
`?share=`, so public share browsing is unchanged.
- `shares::create` never checked the source root's mode, so a user with a
read-only root could create a writable share of it and write through the
share. It now refuses, which is the hole the Mode enum below prevents
from reappearing.
- `archive::walk` followed symlinks without re-checking containment, so a
link inside the served root pointing outside it put those files in the
tar/zip. Browse and upload were already safe because they canonicalize;
the archive path was the one that did not. It now canonicalizes each
child against the archive root, skipping strays with a warning, and caps
depth as a symlink-cycle guard.
- Argon2 ran while holding the single SQLite connection mutex, so every
login serialized all database access for the length of a deliberately
slow KDF. The guard is now scoped to the query and both hashing and
verification run on spawn_blocking.
- The dev asset reader joined the request path onto the dist directory,
and hyper does not normalize literal `..` segments. Non-`Normal` path
components are rejected; covered end to end through the router.
Wire types: `mode` ("rw"/"ro") and `Mutation::op` become serde enums in
`api-types`. The mode was a String in five structs and hand-compared in
seven places, which is exactly why one of them forgot the check above.
Serde spellings are pinned by tests because those strings are also the
values stored in the SQLite `mode` columns.
Frontend: net -130 lines. Deleted `fetch_content` (a copy of
`fetch_content_meta`), collapsed four hand-rolled fetch/status/parse
blocks behind one helper, took Leptos signals by value instead of by
reference-then-deref, resolved `toast` from context at the leaf instead of
drilling it through four levels, replaced `Arc<Mutex<..>>` wasm state with
`StoredValue`, and merged the editor's two identical save callbacks. Hash
path segments are now percent-encoded, so a folder named `we#ird` no
longer truncates the URL.
Server cleanups: one tar builder instead of three, a simpler `hex_token`,
and the two `LazyLock<String>` query statics written out inline.
Verified in a browser against the release binary: sniffed kinds correct on
misnamed files, editor saves to disk, and a shared HTML page runs its own
script while `window.origin` is null, `document.cookie` throws, and its
API calls fail CORS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Mapi-types/src/lib.rs
@@ -41,12 +41,53 @@ pub const P_SHARE: &str = "share";
pub const P_OVERWRITE: &str = "overwrite";
// ---------------------------------------------------------------------------
// Mutation ops (`Mutation::op`)
// Wire enums
// ---------------------------------------------------------------------------
pub const OP_RENAME: &str = "rename";
pub const OP_MOVE: &str = "move";
pub const OP_COPY: &str = "copy";
/// Access mode of a root or a share.
///
/// The serde names are also the values stored in the SQLite `mode` columns,
/// so renaming a variant would break existing databases. The round-trip test
/// below pins them.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Rw,
Ro,
}
impl Mode {
/// The only question callers ask: may this root be written to?
pub fn is_writable(self) -> bool {
matches!(self, Mode::Rw)
}
/// The wire/database spelling, for `<select>` values and SQL params.
pub fn as_str(self) -> &'static str {
match self {
Mode::Rw => "rw",
Mode::Ro => "ro",
}
}
/// Parse the wire spelling. `None` for anything else.
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"rw" => Some(Mode::Rw),
"ro" => Some(Mode::Ro),
_ => None,
}
}
}
/// Which mutation [`Mutation`] asks for.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Op {
Rename,
Move,
Copy,
}
// ---------------------------------------------------------------------------
// Request bodies (client → server)
@@ -61,8 +102,7 @@ pub struct Credentials {
/// Rename / move / copy (one body for all file mutations).
#[derive(Serialize, Deserialize)]
pub struct Mutation {
/// One of [`OP_RENAME`], [`OP_MOVE`], [`OP_COPY`].
pub op: String,
pub op: Op,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -79,13 +119,12 @@ pub struct Mutation {
pub struct Root {
/// Path relative to the server root; "." means the whole root.
pub path: String,
/// "rw" or "ro".
#[serde(default = "default_rw")]
pub mode: String,
pub mode: Mode,
}
fn default_rw() -> String {
"rw".to_string()
fn default_rw() -> Mode {
Mode::Rw
}
#[derive(Serialize, Deserialize)]
@@ -182,7 +221,7 @@ pub struct RootInfo {
pub id: i64,
pub name: String,
pub path: String,
pub mode: String,
pub mode: Mode,
}
/// GET `{AUTH_ME}`.
@@ -249,10 +288,40 @@ pub struct SaveResp {
mod tests {
use super::*;
/// The serde spellings are the database values too, so they are pinned.
#[test]
fn mode_wire_format_is_rw_ro() {
assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
for m in [Mode::Rw, Mode::Ro] {
let s = serde_json::to_string(&m).unwrap();
assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
// `as_str`/`from_wire` must agree with serde.
assert_eq!(s, format!("\"{}\"", m.as_str()));
assert_eq!(Mode::from_wire(m.as_str()), Some(m));
}
assert_eq!(Mode::from_wire("both"), None);
assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
assert!(Mode::Rw.is_writable());
assert!(!Mode::Ro.is_writable());
}
#[test]
fn op_wire_format() {
assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
for op in [Op::Rename, Op::Move, Op::Copy] {
let s = serde_json::to_string(&op).unwrap();
assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
}
assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
}
#[test]
fn mutation_round_trip_skips_absent_fields() {
let m = Mutation {
op: OP_MOVE.to_string(),
op: Op::Move,
new_name: None,
dst_root_id: Some(3),
dst: Some("docs".into()),
@@ -262,7 +331,7 @@ mod tests {
assert!(!s.contains("new_name"));
let back: Mutation = serde_json::from_str(&s).unwrap();
assert_eq!(back.dst_root_id, Some(3));
assert_eq!(back.op, OP_MOVE);
assert_eq!(back.op, Op::Move);
}
#[test]
@@ -275,7 +344,7 @@ mod tests {
#[test]
fn root_defaults_mode_to_rw() {
let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
assert_eq!(r.mode, "rw");
assert_eq!(r.mode, Mode::Rw);
}
#[test]
@@ -291,7 +360,7 @@ mod tests {
id: 1,
name: "root".into(),
path: ".".into(),
mode: "rw".into(),
mode: Mode::Rw,
}],
allow_writable_shares: false,
};
Mserver/src/api/admin.rs
@@ -3,14 +3,13 @@
use std::sync::Arc;
use api_types::{AdminUser, CreateUser, OkResp, Root, RootInfo, Settings, UpdateUser};
use api_types::{AdminUser, CreateUser, Mode, OkResp, Root, RootInfo, Settings, UpdateUser};
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use crate::api::common::AdminUser as AdminGuard;
use crate::api::common::{display_name, validate_name, validate_password};
use crate::auth;
use crate::api::common::{display_name, hash_password, validate_name, validate_password};
use crate::db::Db;
use crate::error::{ApiError, AppState};
use crate::fs;
@@ -24,7 +23,7 @@ fn root_info(state: &AppState, r: &crate::db::RootRow) -> RootInfo {
id: r.id,
name: display_name(&state.root, &r.path),
path: r.path.clone(),
mode: r.mode.clone(),
mode: r.mode,
}
}
@@ -40,11 +39,11 @@ async fn user_info(db: &Db, state: &AppState, user: &crate::db::User) -> AdminUs
}
/// Validate each requested root path (must exist, be a directory, and stay
/// inside the server root) and its mode. Returns the (path, mode) pairs.
async fn validate_roots(
state: &AppState,
roots: &[Root],
) -> Result<Vec<(String, String)>, ApiError> {
/// inside the server root). Returns the (path, mode) pairs.
///
/// The mode needs no check: `Mode` only deserializes from "rw" or "ro", so a
/// bad value is rejected by the `Json` extractor before this runs.
async fn validate_roots(state: &AppState, roots: &[Root]) -> Result<Vec<(String, Mode)>, ApiError> {
let mut out = Vec::new();
for r in roots {
let path = if r.path.trim().is_empty() {
@@ -52,12 +51,6 @@ async fn validate_roots(
} else {
r.path.trim().to_string()
};
if r.mode != "rw" && r.mode != "ro" {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"mode must be 'rw' or 'ro'",
));
}
let server_root = state.root.clone();
let path2 = path.clone();
tokio::task::spawn_blocking(move || fs::resolve_root(&server_root, &path2))
@@ -66,7 +59,7 @@ async fn validate_roots(
.map_err(|e| {
ApiError::new(StatusCode::BAD_REQUEST, format!("root path '{path}': {e}"))
})?;
out.push((path, r.mode.clone()));
out.push((path, r.mode));
}
Ok(out)
}
@@ -105,12 +98,7 @@ pub async fn create_user(
}
let roots = validate_roots(&state, &body.roots).await?;
let pass_hash = auth::hash_password(&body.password).map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("hashing failed: {e}"),
)
})?;
let pass_hash = hash_password(&body.password).await?;
let user = state
.db
.create_user(&name, &pass_hash, body.is_admin, &roots)
@@ -160,12 +148,7 @@ pub async fn update_user(
if let Some(pw) = &body.password {
validate_password(pw)?;
let hash = auth::hash_password(pw).map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("hashing failed: {e}"),
)
})?;
let hash = hash_password(pw).await?;
state.db.update_user_password(id, &hash).await?;
}
if let Some(is_admin) = body.is_admin {
Mserver/src/api/auth.rs
@@ -6,7 +6,7 @@ use axum::extract::State;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use crate::api::common::{display_name, validate_name, validate_password};
use crate::api::common::{display_name, hash_password, validate_name, validate_password};
use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie};
use crate::error::{ApiError, AppState};
@@ -79,12 +79,7 @@ pub async fn setup(
));
}
let pass_hash = auth::hash_password(&body.password).map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("hashing failed: {e}"),
)
})?;
let pass_hash = hash_password(&body.password).await?;
let user = state.db.create_admin(name, &pass_hash).await?;
let token = auth::random_token();
Mserver/src/api/common.rs
@@ -11,8 +11,12 @@ use crate::auth::parse_session_cookie;
use crate::db::{RootRow, ShareRow, User};
use crate::error::{ApiError, AppState};
/// Authorization for the file API: which roots the caller may touch.
///
/// Deliberately carries no [`User`]. A share visitor is anonymous, so there
/// is no identity to expose here. A handler that trusted a user id from this
/// extractor would treat a share visitor as the share's creator.
pub struct AuthUser {
pub user: User,
pub roots: Vec<RootRow>,
/// Present when authenticated via a public share token. The single entry
/// in `roots` is the shared item (its path is the share's `target`), so all
@@ -39,16 +43,9 @@ where
let roots = vec![RootRow {
id: share.id,
path: share.target.clone(),
mode: share.mode.clone(),
mode: share.mode,
}];
let user = User {
id: share.creator_id,
name: "shared".to_string(),
is_admin: false,
active: true,
};
Ok(AuthUser {
user,
roots,
share: Some(share),
})
@@ -59,27 +56,57 @@ where
}
// 2. Signed-in session.
if let Some(token) = parse_session_cookie(&parts.headers) {
return match state.db.session_user(&token).await {
Some(user) => {
let roots = state.db.user_roots(user.id).await;
Ok(AuthUser {
user,
roots,
share: None,
})
}
None => Err(ApiError::new(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
)),
};
}
let (_user, roots) = session_auth(parts, state).await?;
Ok(AuthUser { roots, share: None })
}
}
/// Extractor for routes that must never be reachable with a share token:
/// share management and admin.
///
/// A share token only proves that the caller holds a share link. It says
/// nothing about *who* the caller is, so it must not stand in for the share
/// creator's identity. Requests that carry one are rejected outright instead
/// of silently falling back to the session, so a share visitor cannot act as
/// the creator by also having a cookie.
pub struct SessionUser {
pub user: User,
pub roots: Vec<RootRow>,
}
impl<S> FromRequestParts<S> for SessionUser
where
S: HasState + Send + Sync,
{
type Rejection = ApiError;
Err(ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"))
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
if share_token_from_request(parts).is_some() {
return Err(ApiError::new(
StatusCode::FORBIDDEN,
"a share token cannot be used here; sign in instead",
));
}
let (user, roots) = session_auth(parts, state.state()).await?;
Ok(SessionUser { user, roots })
}
}
/// Authenticate via the session cookie only, returning the user and roots.
async fn session_auth(parts: &Parts, state: &AppState) -> Result<(User, Vec<RootRow>), ApiError> {
let Some(token) = parse_session_cookie(&parts.headers) else {
return Err(ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"));
};
let Some(user) = state.db.session_user(&token).await else {
return Err(ApiError::new(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
));
};
let roots = state.db.user_roots(user.id).await;
Ok((user, roots))
}
/// Extract the share token from a request, if present: a `?share=<token>`
/// query param or an `X-Share-Token` header.
fn share_token_from_request(parts: &Parts) -> Option<String> {
@@ -141,6 +168,21 @@ pub(crate) fn validate_name(name: &str) -> Result<(), ApiError> {
Ok(())
}
/// Hash a password off the async executor. Argon2 is slow by design, so
/// running it inline would block a tokio worker thread for the whole cost.
pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
let pw = pw.to_string();
tokio::task::spawn_blocking(move || crate::auth::hash_password(&pw))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))?
.map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("hashing failed: {e}"),
)
})
}
pub(crate) fn validate_password(pw: &str) -> Result<(), ApiError> {
if pw.len() < 8 {
return Err(ApiError::new(
@@ -152,6 +194,7 @@ pub(crate) fn validate_password(pw: &str) -> Result<(), ApiError> {
}
/// Extractor for admin-only routes: a signed-in user who is an admin.
/// Built on [`SessionUser`], so a share token never grants admin.
pub struct AdminUser {
pub user: User,
}
@@ -163,7 +206,7 @@ where
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let auth = AuthUser::from_request_parts(parts, state).await?;
let auth = SessionUser::from_request_parts(parts, state).await?;
if !auth.user.is_admin {
return Err(ApiError::new(StatusCode::FORBIDDEN, "admin only"));
}
Mserver/src/api/files.rs
@@ -30,9 +30,7 @@ use crate::archive::{self, ArchiveFormat};
use crate::db::{RootRow, ShareRow};
use crate::error::{ApiError, AppState};
use crate::fs::{self, FsError};
use api_types::{
FilesResp, Mutation, OP_COPY, OP_MOVE, OP_RENAME, OkResp, P_OVERWRITE, SaveResp, UploadResp,
};
use api_types::{FilesResp, Mutation, OkResp, Op, P_OVERWRITE, SaveResp, UploadResp};
/// Upper bound for the in-memory text endpoint (preview, later editor).
const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
@@ -529,8 +527,8 @@ async fn mutation(
req_rel: String,
body: Mutation,
) -> Result<Json<OkResp>, ApiError> {
match body.op.as_str() {
OP_RENAME => {
match body.op {
Op::Rename => {
let new_name = body
.new_name
.as_deref()
@@ -550,14 +548,15 @@ async fn mutation(
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(OkResp { ok: true }))
}
OP_MOVE | OP_COPY => {
Op::Move | Op::Copy => {
let dst_root_id = body
.dst_root_id
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
let dst = body.dst.clone().unwrap_or_default();
// Moving or copying out of a folder requires rw there; copying
// *from* a read-only root is fine.
let src_root = if body.op == "move" {
let op_is_move = body.op == Op::Move;
let src_root = if op_is_move {
require_rw_root(&auth.roots, root_id)?
} else {
find_root(&auth.roots, root_id)?
@@ -571,7 +570,6 @@ async fn mutation(
req_rel,
body.overwrite,
);
let op_is_move = body.op == OP_MOVE;
tokio::task::spawn_blocking(move || {
if op_is_move {
fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
@@ -583,10 +581,6 @@ async fn mutation(
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(OkResp { ok: true }))
}
_ => Err(ApiError::new(
StatusCode::BAD_REQUEST,
"unknown op (expected rename, move or copy)",
)),
}
}
@@ -805,7 +799,7 @@ fn find_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
fn require_rw_root(roots: &[RootRow], root_id: i64) -> Result<&RootRow, ApiError> {
let root = find_root(roots, root_id)?;
if root.mode != "rw" {
if !root.mode.is_writable() {
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only folder"));
}
Ok(root)
Mserver/src/archive.rs
@@ -60,9 +60,15 @@ pub fn build(
sink: impl Write,
) -> io::Result<()> {
match format {
ArchiveFormat::Tar => build_tar(dir, top_name, sink),
ArchiveFormat::TarGz => build_tar_gz(dir, top_name, sink),
ArchiveFormat::TarZst => build_tar_zst(dir, top_name, sink),
ArchiveFormat::Tar => build_tar(dir, top_name, sink).map(|_| ()),
ArchiveFormat::TarGz => {
let enc = flate2::write::GzEncoder::new(sink, flate2::Compression::default());
build_tar(dir, top_name, enc)?.finish().map(|_| ())
}
ArchiveFormat::TarZst => {
let enc = zstd::stream::write::Encoder::new(sink, 3)?;
build_tar(dir, top_name, enc)?.finish().map(|_| ())
}
ArchiveFormat::Zip => build_zip(dir, top_name, sink),
}
}
@@ -76,6 +82,10 @@ fn mtime_secs(p: &Path) -> u64 {
.unwrap_or(0)
}
/// Cycle guard: a symlink loop would otherwise recurse forever. Real trees
/// this deep are not worth archiving, so deeper levels are dropped.
const MAX_DEPTH: usize = 64;
/// Depth-first walk. Invokes `f(entry_name, abs_path, is_dir)` for the
/// directory itself and every descendant. Directory entry names carry no
/// trailing slash; each format appends it as needed. Deterministic order
@@ -84,14 +94,40 @@ fn walk<F: FnMut(&str, &Path, bool) -> io::Result<()>>(
abs_dir: &Path,
entry_prefix: &str,
f: &mut F,
) -> io::Result<()> {
// The canonical top directory is the containment boundary for the whole
// walk. Unlike browse and upload, nothing else re-checks it here.
let base = abs_dir.canonicalize()?;
walk_in(&base, &base, entry_prefix, 0, f)
}
fn walk_in<F: FnMut(&str, &Path, bool) -> io::Result<()>>(
base: &Path,
abs_dir: &Path,
entry_prefix: &str,
depth: usize,
f: &mut F,
) -> io::Result<()> {
f(entry_prefix, abs_dir, true)?;
if depth >= MAX_DEPTH {
tracing::warn!(path = %abs_dir.display(), "archive: depth limit reached, subtree skipped");
return Ok(());
}
let rd = std::fs::read_dir(abs_dir)?;
let mut children: Vec<(String, PathBuf, bool)> = Vec::new();
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
let p = e.path();
// Follows symlinks (a broken link shows up as an empty file).
// Resolve symlinks: a link inside the tree may point outside it, and
// its contents must not end up in the archive. One bad entry is
// skipped instead of failing the whole download.
let Ok(p) = e.path().canonicalize() else {
tracing::warn!(path = %e.path().display(), "archive: unreadable entry skipped");
continue;
};
if !crate::fs::is_within_or_eq(base, &p) {
tracing::warn!(path = %e.path().display(), "archive: entry outside the archive root skipped");
continue;
}
let is_dir = p.is_dir();
children.push((name, p, is_dir));
}
@@ -99,7 +135,7 @@ fn walk<F: FnMut(&str, &Path, bool) -> io::Result<()>>(
for (name, p, is_dir) in children {
let child = format!("{entry_prefix}/{name}");
if is_dir {
walk(&p, &child, f)?;
walk_in(base, &p, &child, depth + 1, f)?;
} else {
f(&child, &p, false)?;
}
@@ -139,38 +175,16 @@ fn tar_add<W: Write>(
Ok(())
}
fn build_tar<W: Write>(dir: &Path, top: &str, sink: W) -> io::Result<()> {
/// Write the tar stream into `sink` and hand `sink` back, so a caller that
/// wrapped it in a compressor can finish that compressor.
fn build_tar<W: Write>(dir: &Path, top: &str, sink: W) -> io::Result<W> {
let mut tar = tar::Builder::new(sink);
let mut add = |entry: &str, abs: &Path, is_dir: bool| -> io::Result<()> {
tar_add(&mut tar, entry, abs, is_dir)
};
walk(dir, top, &mut add)?;
tar.finish()?;
Ok(())
}
fn build_tar_gz<W: Write>(dir: &Path, top: &str, sink: W) -> io::Result<()> {
let enc = flate2::write::GzEncoder::new(sink, flate2::Compression::default());
let mut tar = tar::Builder::new(enc);
let mut add = |entry: &str, abs: &Path, is_dir: bool| -> io::Result<()> {
tar_add(&mut tar, entry, abs, is_dir)
};
walk(dir, top, &mut add)?;
let enc = tar.into_inner()?;
enc.finish()?;
Ok(())
}
fn build_tar_zst<W: Write>(dir: &Path, top: &str, sink: W) -> io::Result<()> {
let enc = zstd::stream::write::Encoder::new(sink, 3)?;
let mut tar = tar::Builder::new(enc);
let mut add = |entry: &str, abs: &Path, is_dir: bool| -> io::Result<()> {
tar_add(&mut tar, entry, abs, is_dir)
};
walk(dir, top, &mut add)?;
let enc = tar.into_inner()?;
enc.finish()?;
Ok(())
tar.into_inner()
}
// ---------------------------------------------------------------------------
Mserver/src/assets.rs
@@ -41,8 +41,24 @@ fn dev_dist_dir() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../web/dist"))
}
/// True if the requested asset path may be joined onto the dist directory.
///
/// `path` comes straight from the request URI and hyper does not normalize
/// `..`, so only plain relative paths are allowed. Anything else (a `..`
/// segment, a leading `/`, a Windows prefix) could read outside the dist dir.
#[cfg(not(feature = "embedded"))]
fn is_safe_asset_path(path: &str) -> bool {
!path.is_empty()
&& std::path::Path::new(path)
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
}
#[cfg(not(feature = "embedded"))]
fn read(path: &str) -> Option<(Vec<u8>, bool)> {
if !is_safe_asset_path(path) {
return None;
}
let p = dev_dist_dir().join(path);
if p.is_file() {
std::fs::read(&p).ok().map(|b| (b, true))
@@ -50,3 +66,26 @@ fn read(path: &str) -> Option<(Vec<u8>, bool)> {
None
}
}
#[cfg(all(test, not(feature = "embedded")))]
mod tests {
use super::is_safe_asset_path;
#[test]
fn asset_paths_outside_dist_are_rejected() {
assert!(is_safe_asset_path("index.html"));
assert!(is_safe_asset_path("assets/app-abc123.js"));
// `components()` drops interior "." segments, so this stays inside.
assert!(is_safe_asset_path("assets/./app.js"));
for bad in [
"",
"..",
"../secret",
"assets/../../secret",
"/etc/passwd",
"./index.html",
] {
assert!(!is_safe_asset_path(bad), "{bad:?} must be rejected");
}
}
}
Mserver/src/auth.rs
@@ -34,13 +34,13 @@ pub fn share_token() -> String {
fn hex_token(bytes: usize) -> String {
use rand::RngCore;
let mut b = [0u8; 64];
rand::thread_rng().fill_bytes(&mut b[..bytes.min(64)]);
let mut s = String::with_capacity(bytes * 2);
for x in b[..bytes.min(64)].iter() {
s.push_str(&format!("{x:02x}"));
}
s
use std::fmt::Write as _;
let mut b = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut b);
b.iter().fold(String::with_capacity(bytes * 2), |mut s, x| {
let _ = write!(s, "{x:02x}");
s
})
}
pub fn session_cookie(token: &str, https: bool) -> String {
Mserver/src/db.rs
@@ -1,10 +1,33 @@
use std::path::Path;
use std::sync::Arc;
pub use api_types::Mode;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA_VERSION: i64 = 2;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
///
/// The stored strings are unchanged ("rw"/"ro"), so old databases still read.
struct SqlMode(Mode);
impl FromSql for SqlMode {
fn column_result(v: ValueRef<'_>) -> FromSqlResult<Self> {
let s = v.as_str()?;
Mode::from_wire(s)
.map(SqlMode)
.ok_or_else(|| FromSqlError::Other(format!("unknown mode {s:?}").into()))
}
}
impl ToSql for SqlMode {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(ToSqlOutput::from(self.0.as_str()))
}
}
#[derive(Debug, Clone)]
pub struct User {
pub id: i64,
@@ -19,8 +42,7 @@ pub struct RootRow {
pub id: i64,
/// Path relative to the server root; "." means the whole root.
pub path: String,
/// "rw" or "ro"
pub mode: String,
pub mode: Mode,
}
#[derive(Debug, Clone)]
@@ -31,8 +53,7 @@ pub struct ShareRow {
/// Path of the shared item relative to the server root.
pub target: String,
pub is_file: bool,
/// "rw" or "ro"
pub mode: String,
pub mode: Mode,
pub created_at: String,
pub expires_at: Option<String>,
}
@@ -144,21 +165,31 @@ impl Db {
}
pub async fn verify_password(&self, name: &str, password: &str) -> Option<User> {
let c = self.0.lock().await;
let row: Option<(i64, String, bool, String, bool)> = c
.query_row(
// 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.
let row: Option<(i64, String, bool, String, bool)> = {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, pass_hash, active != 0 FROM users WHERE name = ?1",
[name],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)
.optional()
.ok()
.flatten();
.flatten()
};
let (id, name, is_admin, hash, active) = row?;
if !active {
return None;
}
crate::auth::verify_password(password, &hash).then_some(User {
// Argon2 is CPU-bound, so it must not run on an async worker thread.
let password = password.to_string();
let ok =
tokio::task::spawn_blocking(move || crate::auth::verify_password(&password, &hash))
.await
.unwrap_or(false);
ok.then_some(User {
id,
name,
is_admin,
@@ -217,7 +248,7 @@ impl Db {
Ok(RootRow {
id: r.get(0)?,
path: r.get(1)?,
mode: r.get(2)?,
mode: r.get::<_, SqlMode>(2)?.0,
})
}) {
out.extend(rows.flatten());
@@ -296,7 +327,7 @@ impl Db {
name: &str,
pass_hash: &str,
is_admin: bool,
roots: &[(String, String)],
roots: &[(String, Mode)],
) -> Result<User, rusqlite::Error> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
@@ -309,7 +340,7 @@ impl Db {
for (path, mode) in roots {
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
params![user_id, path, mode],
params![user_id, path, SqlMode(*mode)],
)?;
}
tx.commit()?;
@@ -364,7 +395,7 @@ impl Db {
pub async fn set_user_roots(
&self,
user_id: i64,
roots: &[(String, String)],
roots: &[(String, Mode)],
) -> Result<(), rusqlite::Error> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
@@ -372,7 +403,7 @@ impl Db {
for (path, mode) in roots {
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
params![user_id, path, mode],
params![user_id, path, SqlMode(*mode)],
)?;
}
tx.commit()?;
@@ -387,7 +418,7 @@ impl Db {
token: &str,
target: &str,
is_file: bool,
mode: &str,
mode: Mode,
expires_at: Option<&str>,
) -> Result<ShareRow, rusqlite::Error> {
let c = self.0.lock().await;
@@ -399,7 +430,7 @@ impl Db {
creator_id,
target,
is_file as i64,
mode,
SqlMode(mode),
now(),
expires_at
],
@@ -411,7 +442,7 @@ impl Db {
creator_id,
target: target.to_string(),
is_file,
mode: mode.to_string(),
mode,
created_at: now(),
expires_at: expires_at.map(|s| s.to_string()),
})
@@ -419,14 +450,16 @@ impl Db {
pub async fn share_by_token(&self, token: &str) -> Option<ShareRow> {
let c = self.0.lock().await;
let sql = SHARE_BY_TOKEN.as_str();
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
FROM shares WHERE token = ?1";
c.query_row(sql, [token], map_share).ok()
}
pub async fn user_shares(&self, creator_id: i64) -> Vec<ShareRow> {
let c = self.0.lock().await;
let mut out = Vec::new();
let sql = USER_SHARES.as_str();
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at
FROM shares WHERE creator_id = ?1 ORDER BY id DESC";
if let Ok(mut stmt) = c.prepare(sql)
&& let Ok(rows) = stmt.query_map([creator_id], map_share)
{
@@ -478,13 +511,7 @@ impl Db {
}
}
const SHARE_COLS: &str = "id, token, creator_id, target, is_file, mode, created_at, expires_at";
static SHARE_BY_TOKEN: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| format!("SELECT {SHARE_COLS} FROM shares WHERE token = ?1"));
static USER_SHARES: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
format!("SELECT {SHARE_COLS} FROM shares WHERE creator_id = ?1 ORDER BY id DESC")
});
/// Column order matched by the two `shares` SELECTs above.
fn map_share(r: &rusqlite::Row) -> rusqlite::Result<ShareRow> {
Ok(ShareRow {
id: r.get(0)?,
@@ -492,7 +519,7 @@ fn map_share(r: &rusqlite::Row) -> rusqlite::Result<ShareRow> {
creator_id: r.get(2)?,
target: r.get(3)?,
is_file: r.get::<_, i64>(4)? != 0,
mode: r.get(5)?,
mode: r.get::<_, SqlMode>(5)?.0,
created_at: r.get(6)?,
expires_at: r.get(7)?,
})
@@ -612,7 +639,7 @@ mod tests {
let roots = db.user_roots(admin.id).await;
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].path, ".");
assert_eq!(roots[0].mode, "rw");
assert_eq!(roots[0].mode, Mode::Rw);
assert!(db.verify_password("admin", "admin1234").await.is_some());
assert!(db.verify_password("admin", "nope").await.is_none());
@@ -646,7 +673,7 @@ mod tests {
let (db, _admin) = db_with_admin().await;
let h = crate::auth::hash_password("bobpass1").unwrap();
let bob = db
.create_user("bob", &h, false, &[("docs".into(), "rw".into())])
.create_user("bob", &h, false, &[("docs".into(), Mode::Rw)])
.await
.unwrap();
assert!(!bob.is_admin);
@@ -666,15 +693,12 @@ mod tests {
// Root replacement semantics.
let roots = db.user_roots(bob.id).await;
assert_eq!(roots.len(), 1);
db.set_user_roots(
bob.id,
&[(".".into(), "ro".into()), ("docs".into(), "rw".into())],
)
.await
.unwrap();
db.set_user_roots(bob.id, &[(".".into(), Mode::Ro), ("docs".into(), Mode::Rw)])
.await
.unwrap();
let roots = db.user_roots(bob.id).await;
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|r| r.path == "." && r.mode == "ro"));
assert!(roots.iter().any(|r| r.path == "." && r.mode == Mode::Ro));
db.set_user_roots(bob.id, &[]).await.unwrap();
assert!(db.user_roots(bob.id).await.is_empty());
@@ -705,7 +729,7 @@ mod tests {
creator_id: 1,
target: "docs".into(),
is_file: false,
mode: "ro".into(),
mode: Mode::Ro,
created_at: "2024-01-01T00:00:00Z".into(),
expires_at: expires_at.map(str::to_string),
}
@@ -724,7 +748,7 @@ mod tests {
async fn shares_crud() {
let (db, admin) = db_with_admin().await;
let s1 = db
.create_share(admin.id, "tok-a", "docs", false, "ro", None)
.create_share(admin.id, "tok-a", "docs", false, Mode::Ro, None)
.await
.unwrap();
let s2 = db
@@ -733,7 +757,7 @@ mod tests {
"tok-b",
"file.txt",
true,
"rw",
Mode::Rw,
Some("2999-01-01T00:00:00Z"),
)
.await
@@ -742,7 +766,7 @@ mod tests {
let found = db.share_by_token("tok-b").await.unwrap();
assert!(found.is_file);
assert_eq!(found.mode, "rw");
assert_eq!(found.mode, Mode::Rw);
assert!(db.share_by_token("nope").await.is_none());
// Listed newest-first.
Mserver/src/fs.rs
@@ -384,7 +384,7 @@ fn io_err(e: std::io::Error, p: &Path) -> FsError {
}
/// True if `a` is `b` or a descendant of `b` (both canonical).
fn is_within_or_eq(base: &Path, p: &Path) -> bool {
pub(crate) fn is_within_or_eq(base: &Path, p: &Path) -> bool {
p == base || p.starts_with(base)
}
Mserver/tests/api_admin.rs
@@ -102,14 +102,15 @@ async fn user_lifecycle() {
"root '{bad}' should be rejected"
);
}
// Bad mode → 400.
// Bad mode → 422: `api_types::Mode` only accepts "rw"/"ro", so the JSON
// extractor rejects the body before the handler runs.
let r = admin
.post_json(
"/api/admin/users",
&json!({ "name": "dave", "password": "longenough1", "roots": [{ "path": "docs", "mode": "both" }] }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY);
// Update: password reset.
let r = admin
Mserver/tests/api_files.rs
@@ -421,7 +421,8 @@ async fn mkdir_and_rename() {
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// Unknown op.
// Unknown op: `api_types::Op` has no such variant, so the body fails to
// deserialize. `dispatch_inner` parses it itself, so this stays a 400.
let r = admin
.post_json(&root_path("notes.md"), &json!({ "op": "explode" }))
.await;
@@ -811,3 +812,28 @@ async fn scriptable_files_are_served_sandboxed() {
"{csp}"
);
}
#[tokio::test]
async fn archive_does_not_follow_symlinks_out_of_the_root() {
let env = Env::new().await;
let admin = env.admin().await;
// A directory outside the served root, linked to from inside it.
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.txt"), "leaked").unwrap();
std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap();
let r = admin
.get(&format!("{}?action=download&format=tar", root_path("docs")))
.await;
assert_eq!(r.status, StatusCode::OK);
let map = tar_map(&r.body, Compress::None);
assert!(
!map.keys().any(|k| k.contains("secret.txt")),
"archive escaped the root: {:?}",
map.keys().collect::<Vec<_>>()
);
// The legitimate entries are still there.
assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
}
Mserver/tests/api_spa.rs
@@ -60,6 +60,23 @@ async fn spa_fallback_and_api_guards() {
assert_eq!(r.text(), "body{}");
assert_eq!(r.header("content-type").as_deref(), Some("text/css"));
// Traversal: the dev reader joins the request path onto the dist dir, and
// hyper does not normalize literal `..` segments. Such a path must fall
// through to the SPA page, never to a file outside the dist dir.
std::fs::write(dist.path().parent().unwrap().join("outside.txt"), "SECRET").unwrap();
for p in [
"/../outside.txt",
"/../../etc/passwd",
"/sub/../../outside.txt",
] {
let r = c.get(p).await;
let body = r.text();
assert!(
!body.contains("SECRET") && !body.contains("root:x:"),
"{p} leaked a file outside the dist dir: {body}"
);
}
// Unknown paths fall back to index.html (SPA client routes, deep links).
let r = c.get("/some/deep/client/route").await;
assert_eq!(r.status, StatusCode::OK);
Mweb/src/api.rs
@@ -13,11 +13,10 @@ use wasm_bindgen_futures::JsFuture;
use api_types::{
ACTION_CONTENT, ACTION_DOWNLOAD, ACTION_PREVIEW, ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN,
AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare, CreateUser, Credentials, FILES, Mutation,
OP_COPY, OP_MOVE, OP_RENAME, P_ACTION, P_FORMAT, P_OVERWRITE, P_SHARE, Root, SHARE, SHARES,
Settings, UpdateUser,
P_ACTION, P_FORMAT, P_OVERWRITE, P_SHARE, Root, SHARE, SHARES, Settings, UpdateUser,
};
pub use api_types::{
AdminUser, Entry, FilesResp, Me, OkResp, RootInfo, SaveResp, ShareInfo, UserInfo,
AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo, UserInfo,
};
#[derive(Debug, thiserror::Error)]
@@ -171,7 +170,7 @@ pub fn rename_item(
"POST",
files_url(root_id, path),
Some(Mutation {
op: OP_RENAME.to_string(),
op: Op::Rename,
new_name: Some(new_name),
dst_root_id: None,
dst: None,
@@ -187,7 +186,7 @@ pub fn move_item(
dst: &str,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
mutation(root_id, path, OP_MOVE, dst_root_id, dst, overwrite)
mutation(root_id, path, Op::Move, dst_root_id, dst, overwrite)
}
pub fn copy_item(
@@ -197,13 +196,13 @@ pub fn copy_item(
dst: &str,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
mutation(root_id, path, OP_COPY, dst_root_id, dst, overwrite)
mutation(root_id, path, Op::Copy, dst_root_id, dst, overwrite)
}
fn mutation(
root_id: i64,
path: &str,
op: &str,
op: Op,
dst_root_id: i64,
dst: &str,
overwrite: bool,
@@ -212,7 +211,7 @@ fn mutation(
"POST",
files_url(root_id, path),
Some(Mutation {
op: op.to_string(),
op,
new_name: None,
dst_root_id: Some(dst_root_id),
dst: Some(dst.to_string()),
@@ -260,72 +259,16 @@ pub fn content_url(root_id: i64, path: &str) -> String {
)
}
/// Fetch a file's raw text content (for the CodeMirror preview/editor).
pub async fn fetch_content(root_id: i64, path: &str) -> Result<String, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let opts = web_sys::RequestInit::new();
opts.set_method("GET");
opts.set_mode(web_sys::RequestMode::SameOrigin);
let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts)
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let promise = window.fetch_with_request(&req);
let resp_val = JsFuture::from(promise)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let resp: web_sys::Response = resp_val
.dyn_into()
.map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
let status = resp.status();
if !(200..300).contains(&status) {
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body
.error
.unwrap_or_else(|| "could not read file".to_string()),
skipped: None,
});
}
let tp = resp.text().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js = JsFuture::from(tp)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
js.as_string()
.ok_or_else(|| ApiError::Net("content is not a string".to_string()))
}
/// Fetch a file's raw text content plus its mtime (unix seconds), for the
/// editor. The mtime anchors the save-time conflict check.
/// preview and the editor. The mtime anchors the save-time conflict check.
pub async fn fetch_content_meta(
root_id: i64,
path: &str,
) -> Result<(String, Option<i64>), ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let opts = web_sys::RequestInit::new();
opts.set_method("GET");
opts.set_mode(web_sys::RequestMode::SameOrigin);
let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts)
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let promise = window.fetch_with_request(&req);
let resp_val = JsFuture::from(promise)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let resp: web_sys::Response = resp_val
.dyn_into()
.map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
let status = resp.status();
if !(200..300).contains(&status) {
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body
.error
.unwrap_or_else(|| "could not read file".to_string()),
skipped: None,
});
}
let resp = fetch_checked(&content_url(root_id, path), &opts, "could not read file").await?;
let mtime: Option<i64> = resp
.headers()
.get("x-file-mtime")
@@ -355,8 +298,6 @@ pub async fn save_content(
expected_mtime: Option<i64>,
force: bool,
) -> Result<i64, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let url = content_url(root_id, path);
let opts = web_sys::RequestInit::new();
opts.set_method("PUT");
@@ -373,29 +314,12 @@ pub async fn save_content(
.map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
}
opts.set_headers_headers(&headers);
let req = web_sys::Request::new_with_str_and_init(&url, &opts)
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let promise = window.fetch_with_request(&req);
let resp_val = JsFuture::from(promise)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let resp: web_sys::Response = resp_val
.dyn_into()
.map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
let status = resp.status();
if status == 409 {
return Err(ApiError::Conflict);
}
if !(200..300).contains(&status) {
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body
.error
.unwrap_or_else(|| "could not save file".to_string()),
skipped: None,
});
}
// 409 is the server's "changed on disk" answer, not a generic HTTP error.
let resp = match fetch_checked(&url, &opts, "could not save file").await {
Ok(resp) => resp,
Err(e) if e.status() == Some(409) => return Err(ApiError::Conflict),
Err(e) => return Err(e),
};
let js = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js: JsValue = JsFuture::from(js)
.await
@@ -453,9 +377,6 @@ pub async fn upload(
overwrite: bool,
parts: Vec<(String, web_sys::File)>,
) -> Result<(), ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let boundary = format!("----fbng{}", random_boundary_suffix());
let segments = js_sys::Array::new();
for (name, file) in &parts {
@@ -493,23 +414,8 @@ pub async fn upload(
opts.set_headers_headers(&headers);
opts.set_body_opt_blob(Some(&body));
let promise = window.fetch_with_str_and_init(&url, &opts);
let resp_val = JsFuture::from(promise)
.await
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let resp: web_sys::Response = resp_val
.dyn_into()
.map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
let status = resp.status();
if !(200..300).contains(&status) {
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body.error.unwrap_or_else(|| "upload failed".to_string()),
skipped: body.skipped,
});
}
// The error carries the server's `skipped` list on an upload conflict.
fetch_checked(&url, &opts, "upload failed").await?;
Ok(())
}
@@ -554,12 +460,12 @@ pub fn resolve_share(
// Admin (milestone 7): user management + settings
// ---------------------------------------------------------------------------
fn roots_to_bodies(roots: &[(String, String)]) -> Vec<Root> {
fn roots_to_bodies(roots: &[(String, Mode)]) -> Vec<Root> {
roots
.iter()
.map(|(path, mode)| Root {
path: path.clone(),
mode: mode.clone(),
mode: *mode,
})
.collect()
}
@@ -572,7 +478,7 @@ pub fn create_admin_user(
name: &str,
password: &str,
is_admin: bool,
roots: &[(String, String)],
roots: &[(String, Mode)],
) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
request(
"POST",
@@ -591,7 +497,7 @@ pub fn update_admin_user(
password: Option<String>,
is_admin: Option<bool>,
active: Option<bool>,
roots: Option<Vec<(String, String)>>,
roots: Option<Vec<(String, Mode)>>,
) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
request(
"PUT",
@@ -711,9 +617,6 @@ async fn request<T: DeserializeOwned>(
url: String,
body: Option<impl Serialize>,
) -> Result<T, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let opts = web_sys::RequestInit::new();
opts.set_method(method);
opts.set_mode(web_sys::RequestMode::SameOrigin);
@@ -725,15 +628,21 @@ async fn request<T: DeserializeOwned>(
opts.set_headers_headers(&headers);
}
do_fetch(window, url, opts).await
do_fetch(&url, &opts).await
}
async fn do_fetch<T: DeserializeOwned>(
window: web_sys::Window,
url: String,
opts: web_sys::RequestInit,
) -> Result<T, ApiError> {
let req = web_sys::Request::new_with_str_and_init(&url, &opts)
/// Run one fetch and return the response, turning any non-2xx status into
/// [`ApiError::Http`]. `fallback_msg` is used when the error body has no
/// message. Callers that need the raw response (text, a header, or nothing at
/// all) use this directly; JSON callers go through [`do_fetch`].
async fn fetch_checked(
url: &str,
opts: &web_sys::RequestInit,
fallback_msg: &str,
) -> Result<web_sys::Response, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let req = web_sys::Request::new_with_str_and_init(url, opts)
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let promise = window.fetch_with_request(&req);
@@ -749,10 +658,18 @@ async fn do_fetch<T: DeserializeOwned>(
let body = parse_error_body(&resp).await;
return Err(ApiError::Http {
status,
message: body.error.unwrap_or_else(|| "request failed".to_string()),
message: body.error.unwrap_or_else(|| fallback_msg.to_string()),
skipped: body.skipped,
});
}
Ok(resp)
}
async fn do_fetch<T: DeserializeOwned>(
url: &str,
opts: &web_sys::RequestInit,
) -> Result<T, ApiError> {
let resp = fetch_checked(url, opts, "request failed").await?;
let json_promise = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js: JsValue = JsFuture::from(json_promise)
Mweb/src/editor.rs
@@ -2,9 +2,6 @@
//! indicator, Ctrl/Cmd+S, and save-time conflict handling (the file changed on
//! disk since it was opened).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use leptos::prelude::*;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
@@ -32,8 +29,8 @@ pub struct EditTarget {
async fn do_save(
target: EditTarget,
force: bool,
cm_view: &Arc<Mutex<Option<JsValue>>>,
loaded_mtime: &Arc<Mutex<Option<i64>>>,
cm_view: StoredValue<Option<JsValue>, LocalStorage>,
loaded_mtime: StoredValue<Option<i64>>,
set_dirty: WriteSignal<bool>,
set_saving: WriteSignal<bool>,
set_status: WriteSignal<Option<String>>,
@@ -41,22 +38,17 @@ async fn do_save(
toast: ToastMsg,
refresh: Callback<()>,
) {
let text = match cm_view.lock() {
Ok(g) => g.as_ref().and_then(cm::get_value),
Err(_) => None,
};
let text = cm_view.with_value(|v| v.as_ref().and_then(cm::get_value));
let Some(text) = text else {
set_status.set(Some("editor not ready".to_string()));
return;
};
let expected = loaded_mtime.lock().ok().and_then(|g| *g);
let expected = loaded_mtime.get_value();
set_saving.set(true);
set_status.set(None);
match api::save_content(target.root_id, &target.path, &text, expected, force).await {
Ok(new_mtime) => {
if let Ok(mut g) = loaded_mtime.lock() {
*g = Some(new_mtime);
}
loaded_mtime.set_value(Some(new_mtime));
set_dirty.set(false);
set_conflict.set(false);
show(toast, "Saved");
@@ -87,12 +79,12 @@ pub fn EditorModal(
let (conflict, set_conflict) = signal(false);
let (confirm_discard, set_confirm_discard) = signal(false);
// The mtime the loaded content was anchored to (for the conflict check).
let loaded_mtime: Arc<Mutex<Option<i64>>> = Arc::new(Mutex::new(None));
let loaded_mtime = StoredValue::new(Option::<i64>::None);
// The live CodeMirror view (destroyed on close).
let cm_view: Arc<Mutex<Option<JsValue>>> = Arc::new(Mutex::new(None));
let cm_view = StoredValue::new_local(Option::<JsValue>::None);
// Set before a programmatic `set_value` so the resulting doc change is not
// counted as a user edit (keeps the dirty state honest).
let suppress = Arc::new(AtomicBool::new(false));
let suppress = StoredValue::new(false);
let name = target.name.clone();
@@ -100,16 +92,16 @@ pub fn EditorModal(
{
let t = target.clone();
let st = set_status;
let h = cm_view.clone();
let sup = suppress.clone();
let lt = loaded_mtime.clone();
let sd = set_dirty;
node.on_load(move |el: web_sys::HtmlDivElement| {
spawn_local(async move {
match api::fetch_content_meta(t.root_id, &t.path).await {
Ok((text, mtime)) => {
let closure = Closure::<dyn FnMut(JsValue)>::new(move |_| {
if sup.swap(false, Ordering::SeqCst) {
// Consume the suppression flag: only the change
// right after a programmatic set_value is ignored.
if suppress.get_value() {
suppress.set_value(false);
return;
}
sd.set(true);
@@ -120,8 +112,8 @@ pub fn EditorModal(
match cm::create(&el, &text, &t.name, true, Some(js_ref)) {
Ok(c) => {
cm::focus(&c.view);
let _ = h.lock().map(|mut g| *g = Some(c.view.clone()));
let _ = lt.lock().map(|mut g| *g = mtime);
cm_view.set_value(Some(c.view.clone()));
loaded_mtime.set_value(mtime);
}
Err(e) => st.set(Some(e)),
}
@@ -137,78 +129,55 @@ pub fn EditorModal(
}
// Destroy the CodeMirror view when the editor closes.
{
let h = cm_view.clone();
on_cleanup(move || {
if let Ok(mut g) = h.lock()
&& let Some(v) = g.take()
{
cm::destroy(&v);
}
});
}
// Save (with the conflict check).
let save_cb: Callback<()> = {
let t = target.clone();
let cmv = cm_view.clone();
let lt = loaded_mtime.clone();
let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict);
let (toast, refresh) = (toast, refresh);
Callback::new(move |_| {
let t2 = t.clone();
let cmv2 = cmv.clone();
let lt2 = lt.clone();
let refresh2 = refresh;
spawn_local(async move {
do_save(t2, false, &cmv2, <2, sd, ss, st, sc, toast, refresh2).await;
});
})
};
on_cleanup(move || {
if let Some(v) = cm_view.try_update_value(Option::take).flatten() {
cm::destroy(&v);
}
});
// Overwrite (skip the conflict check).
let overwrite_cb: Callback<()> = {
// Save and overwrite differ only in the conflict check (`force`).
let make_save = |force: bool| {
let t = target.clone();
let cmv = cm_view.clone();
let lt = loaded_mtime.clone();
let (sd, ss, st, sc) = (set_dirty, set_saving, set_status, set_conflict);
let (toast, refresh) = (toast, refresh);
Callback::new(move |_| {
let t2 = t.clone();
let cmv2 = cmv.clone();
let lt2 = lt.clone();
let refresh2 = refresh;
spawn_local(async move {
do_save(t2, true, &cmv2, <2, sd, ss, st, sc, toast, refresh2).await;
do_save(
t2,
force,
cm_view,
loaded_mtime,
sd,
ss,
st,
sc,
toast,
refresh,
)
.await;
});
})
};
let save_cb: Callback<()> = make_save(false);
let overwrite_cb: Callback<()> = make_save(true);
// Load the latest on-disk version (discards local edits).
let load_latest_cb: Callback<()> = {
let t = target.clone();
let cmv = cm_view.clone();
let lt = loaded_mtime.clone();
let sup = suppress.clone();
let (sd, st, sc) = (set_dirty, set_status, set_conflict);
Callback::new(move |_| {
let t2 = t.clone();
let cmv2 = cmv.clone();
let lt2 = lt.clone();
let sup2 = sup.clone();
spawn_local(async move {
st.set(None);
match api::fetch_content_meta(t2.root_id, &t2.path).await {
Ok((text, mtime)) => {
if let Ok(g) = cmv2.lock()
&& let Some(v) = g.as_ref()
{
sup2.store(true, Ordering::SeqCst);
cm::set_value(v, &text);
}
if let Ok(mut g) = lt2.lock() {
*g = mtime;
}
cm_view.with_value(|v| {
if let Some(v) = v.as_ref() {
suppress.set_value(true);
cm::set_value(v, &text);
}
});
loaded_mtime.set_value(mtime);
sd.set(false);
sc.set(false);
}
Mweb/src/preview.rs
@@ -1,8 +1,6 @@
//! File previews (milestone 4): browser-native media (image/PDF/video/audio)
//! and read-only CodeMirror for text/code.
use std::sync::{Arc, Mutex};
use api_types::FileKind;
use leptos::prelude::*;
use wasm_bindgen::JsValue;
@@ -43,8 +41,6 @@ pub struct PreviewTarget {
pub path: String,
/// Display name (used for the title + language detection).
pub name: String,
#[allow(dead_code)]
pub size: u64,
}
/// The preview overlay.
@@ -129,22 +125,18 @@ fn TextPreview(target: PreviewTarget) -> impl IntoView {
// Some(err) when the file couldn't be loaded.
let (status, set_status) = signal(Option::<String>::None);
// Holds the live CodeMirror view so we can destroy it on close.
let holder: Arc<Mutex<Option<JsValue>>> = Arc::new(Mutex::new(None));
let holder = StoredValue::new_local(Option::<JsValue>::None);
{
let t = target.clone();
let st = set_status;
let h = holder.clone();
node.on_load(move |el: web_sys::HtmlDivElement| {
let t = t.clone();
let st = st;
let h = h.clone();
spawn_local(async move {
match api::fetch_content(t.root_id, &t.path).await {
Ok(text) => match cm::create(&el, &text, &t.name, false, None) {
Ok(c) => {
let _ = h.lock().map(|mut g| *g = Some(c.view.clone()));
}
// The mtime only matters for the editor's conflict check.
match api::fetch_content_meta(t.root_id, &t.path).await {
Ok((text, _)) => match cm::create(&el, &text, &t.name, false, None) {
Ok(c) => holder.set_value(Some(c.view.clone())),
Err(e) => st.set(Some(e)),
},
Err(e) => st.set(Some(e.to_string())),
@@ -153,11 +145,8 @@ fn TextPreview(target: PreviewTarget) -> impl IntoView {
});
}
let h = holder.clone();
on_cleanup(move || {
if let Ok(mut g) = h.lock()
&& let Some(v) = g.take()
{
if let Some(v) = holder.try_update_value(Option::take).flatten() {
cm::destroy(&v);
}
});
Mweb/src/router.rs
@@ -27,6 +27,19 @@ impl Location {
}
}
/// Percent-encode one path segment for the URL hash. Without this a name
/// containing `#`, `%` or `/` would break the round trip.
fn encode_segment(seg: &str) -> String {
js_sys::encode_uri_component(seg).into()
}
fn decode_segment(seg: &str) -> String {
// A hand-typed hash can hold invalid escapes; keep it verbatim then.
js_sys::decode_uri_component(seg)
.map(String::from)
.unwrap_or_else(|_| seg.to_string())
}
pub fn parse_location() -> Location {
let hash = web_sys::window()
.and_then(|w| w.location().hash().ok())
@@ -38,7 +51,7 @@ pub fn parse_location() -> Location {
Some("r") => match parts.next().and_then(|s| s.parse().ok()) {
Some(root_id) => Location {
root_id: Some(root_id),
path: parts.map(String::from).collect(),
path: parts.map(decode_segment).collect(),
share_token: None,
},
None => Location::root(),
@@ -46,7 +59,7 @@ pub fn parse_location() -> Location {
Some("s") => match parts.next() {
Some(token) => Location {
root_id: None,
path: parts.map(String::from).collect(),
path: parts.map(decode_segment).collect(),
share_token: Some(token.to_string()),
},
None => Location::root(),
@@ -60,7 +73,7 @@ pub fn location_to_hash(loc: &Location) -> String {
let mut s = format!("#/s/{token}");
for seg in &loc.path {
s.push('/');
s.push_str(seg);
s.push_str(&encode_segment(seg));
}
return s;
}
@@ -70,7 +83,7 @@ pub fn location_to_hash(loc: &Location) -> String {
let mut s = format!("#/r/{id}");
for seg in &loc.path {
s.push('/');
s.push_str(seg);
s.push_str(&encode_segment(seg));
}
s
}
Mweb/src/views/admin.rs
@@ -5,7 +5,7 @@ use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me};
use crate::api::{self, AdminUser, Me, Mode};
use crate::components::toast::{ToastMsg, show};
// ---------------------------------------------------------------------------
@@ -217,7 +217,7 @@ fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
} else {
u.roots
.iter()
.map(|r| format!("{} ({})", r.path, r.mode))
.map(|r| format!("{} ({})", r.path, r.mode.as_str()))
.collect::<Vec<_>>()
.join(", ")
};
@@ -322,7 +322,7 @@ fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
#[derive(Clone)]
struct RootDraft {
path: String,
mode: String,
mode: Mode,
}
#[component]
@@ -359,7 +359,7 @@ fn UserForm(
.iter()
.map(|r| RootDraft {
path: r.path.clone(),
mode: r.mode.clone(),
mode: r.mode,
})
.collect()
})
@@ -367,7 +367,7 @@ fn UserForm(
if is_new && roots0.is_empty() {
roots0.push(RootDraft {
path: ".".to_string(),
mode: "rw".to_string(),
mode: Mode::Rw,
});
}
let (roots, set_roots) = signal(roots0);
@@ -387,7 +387,7 @@ fn UserForm(
}
v.push(RootDraft {
path: path.clone(),
mode: "rw".to_string(),
mode: Mode::Rw,
});
set_roots.set(v);
set_new_path.set(String::new());
@@ -402,10 +402,10 @@ fn UserForm(
let pw = password.get();
let adm = is_admin.get();
let act = active.get();
let pairs: Vec<(String, String)> = roots
let pairs: Vec<(String, Mode)> = roots
.get()
.iter()
.map(|r| (r.path.clone(), r.mode.clone()))
.map(|r| (r.path.clone(), r.mode))
.collect();
// Client-side validation.
if n.is_empty() {
@@ -566,8 +566,8 @@ fn UserForm(
.iter()
.enumerate()
.map(|(i, r)| {
let rw_selected = r.mode == "rw";
let ro_selected = r.mode == "ro";
let rw_selected = r.mode == Mode::Rw;
let ro_selected = r.mode == Mode::Ro;
view! {
<div class="root-draft">
<span class="root-draft-path">{r.path.clone()}</span>
@@ -578,17 +578,20 @@ fn UserForm(
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
{
let val = t.value();
let mut v = roots_c.get();
if i < v.len() {
v[i].mode = val;
if i < v.len()
&& let Some(m) = Mode::from_wire(
&t.value(),
)
{
v[i].mode = m;
}
set_roots_c.set(v);
}
}
>
<option value="rw" selected=rw_selected>"read-write"</option>
<option value="ro" selected=ro_selected>"read-only"</option>
<option value=Mode::Rw.as_str() selected=rw_selected>"read-write"</option>
<option value=Mode::Ro.as_str() selected=ro_selected>"read-only"</option>
</select>
<button
class="btn btn-sm"
Mweb/src/views/browser.rs
@@ -84,17 +84,16 @@ pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl In
};
let loc_now = loc.get();
match effective_root(&me.roots, &loc_now) {
None => root_picker(&me.roots, &set_ctx, &loc_now).into_any(),
None => root_picker(&me.roots, set_ctx, &loc_now).into_any(),
Some(root) => file_browser(
root,
&loc,
&list_state,
&view_mode,
&set_view_mode,
&set_ctx,
&toast,
&fetch,
&set_preview,
loc,
list_state,
view_mode,
set_view_mode,
set_ctx,
fetch,
set_preview,
)
.into_any(),
}
@@ -156,7 +155,7 @@ pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl In
fn root_picker(
roots: &[RootInfo],
set_ctx: &WriteSignal<Option<CtxMenu>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
loc: &Location,
) -> impl IntoView {
view! {
@@ -180,8 +179,7 @@ fn root_picker(
let id = r.id;
let name = r.name.clone();
let path = r.path.clone();
let mode = r.mode.clone();
let set_ctx = *set_ctx;
let mode = r.mode;
let share_tok = loc.share_token.clone();
view! {
<div
@@ -208,7 +206,7 @@ fn root_picker(
{if path == "." { "/".to_string() } else { path.clone() }}
</div>
<span class="badge">
{if mode == "rw" { "read-write" } else { "read-only" }}
{if mode.is_writable() { "read-write" } else { "read-only" }}
</span>
</div>
}
@@ -228,19 +226,17 @@ fn root_picker(
#[allow(clippy::too_many_arguments)] // explicit signal props
fn file_browser(
root: &RootInfo,
loc: &ReadSignal<Location>,
list_state: &ReadSignal<ListState>,
view_mode: &ReadSignal<ViewMode>,
set_view_mode: &WriteSignal<ViewMode>,
set_ctx: &WriteSignal<Option<CtxMenu>>,
toast: &ToastMsg,
fetch: &Callback<()>,
set_preview: &WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
loc: ReadSignal<Location>,
list_state: ReadSignal<ListState>,
view_mode: ReadSignal<ViewMode>,
set_view_mode: WriteSignal<ViewMode>,
set_ctx: WriteSignal<Option<CtxMenu>>,
fetch: Callback<()>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
) -> impl IntoView {
let root_id = root.id;
let root_name = root.name.clone();
let loc_now = loc.get();
let loc_sig = *loc;
// Breadcrumbs
let crumbs = view! {
@@ -251,7 +247,7 @@ fn file_browser(
navigate(&Location {
root_id: Some(root_id),
path: vec![],
share_token: loc_sig.get().share_token.clone(),
share_token: loc.get().share_token.clone(),
});
}
>
@@ -275,7 +271,7 @@ fn file_browser(
navigate(&Location {
root_id: Some(root_id),
path: target.clone(),
share_token: loc_sig.get().share_token.clone(),
share_token: loc.get().share_token.clone(),
});
}
>
@@ -287,13 +283,11 @@ fn file_browser(
};
// Toolbar (view toggle)
let set_view = *set_view_mode;
let vm = *view_mode;
let toolbar = view! {
<div class="toolbar">
<button
class=move || {
if vm.get() == ViewMode::Grid {
if view_mode.get() == ViewMode::Grid {
"icon-btn active".to_string()
} else {
"icon-btn".to_string()
@@ -301,7 +295,7 @@ fn file_browser(
}
title="Grid view"
on:click=move |_| {
set_view.set(ViewMode::Grid);
set_view_mode.set(ViewMode::Grid);
ViewMode::Grid.save();
}
>
@@ -309,7 +303,7 @@ fn file_browser(
</button>
<button
class=move || {
if vm.get() == ViewMode::List {
if view_mode.get() == ViewMode::List {
"icon-btn active".to_string()
} else {
"icon-btn".to_string()
@@ -317,7 +311,7 @@ fn file_browser(
}
title="List view"
on:click=move |_| {
set_view.set(ViewMode::List);
set_view_mode.set(ViewMode::List);
ViewMode::List.save();
}
>
@@ -326,18 +320,12 @@ fn file_browser(
</div>
};
let ls = *list_state;
let loc2 = *loc;
let set_ctx2 = *set_ctx;
let t = *toast;
let refresh = *fetch;
let set_preview2 = *set_preview;
let entries_area = view! {
<div
class="entries-area"
on:contextmenu=move |ev: MouseEvent| {
ev.prevent_default();
set_ctx2.set(Some(CtxMenu {
set_ctx.set(Some(CtxMenu {
x: ev.client_x(),
y: ev.client_y(),
entry: None,
@@ -345,7 +333,7 @@ fn file_browser(
}
>
{move || {
match ls.get() {
match list_state.get() {
ListState::Loading => view! {
<p class="muted center-note">"Loading…"</p>
}
@@ -354,7 +342,7 @@ fn file_browser(
ListState::Error(msg) => view! {
<div class="card error-card">
<span>{msg.clone()}</span>
<button class="btn" on:click=move |_| refresh.run(())>
<button class="btn" on:click=move |_| fetch.run(())>
"Retry"
</button>
</div>
@@ -366,10 +354,10 @@ fn file_browser(
view! { <p class="muted center-note">"This folder is empty."</p> }
.into_view()
.into_any()
} else if vm.get() == ViewMode::Grid {
grid_view(&entries, root_id, &loc2, &set_ctx2, &t, &set_preview2).into_any()
} else if view_mode.get() == ViewMode::Grid {
grid_view(&entries, root_id, loc, set_ctx, set_preview).into_any()
} else {
list_view(&entries, root_id, &loc2, &set_ctx2, &t, &set_preview2).into_any()
list_view(&entries, root_id, loc, set_ctx, set_preview).into_any()
}
}
}
@@ -387,15 +375,12 @@ fn file_browser(
fn entry_actions(
entry: Entry,
root_id: i64,
loc: &ReadSignal<Location>,
set_ctx: &WriteSignal<Option<CtxMenu>>,
_toast: &ToastMsg,
loc: ReadSignal<Location>,
set_ctx: WriteSignal<Option<CtxMenu>>,
) -> (impl Fn() + 'static, impl Fn(web_sys::MouseEvent) + 'static) {
let l = *loc;
let set_ctx = *set_ctx;
let entry2 = entry.clone();
let nav = move || {
let cur = l.get();
let cur = loc.get();
let mut path = cur.path;
path.push(entry.name.clone());
navigate(&Location {
@@ -421,14 +406,15 @@ fn entry_actions(
fn open_callback(
entry: &Entry,
root_id: i64,
loc: &ReadSignal<Location>,
set_ctx: &WriteSignal<Option<CtxMenu>>,
toast: &ToastMsg,
set_preview: &WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
loc: ReadSignal<Location>,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
) -> (Callback<()>, impl Fn(web_sys::MouseEvent) + 'static) {
let (nav, on_ctx) = entry_actions(entry.clone(), root_id, loc, set_ctx, toast);
let (t, l, sp) = (*toast, *loc, *set_preview);
let (name, is_dir, size, kind) = (entry.name.clone(), entry.is_dir, entry.size, entry.kind);
let (nav, on_ctx) = entry_actions(entry.clone(), root_id, loc, set_ctx);
// Resolved here, during the caller's render: the callback runs later, when
// this reactive owner may already be gone.
let toast = use_context::<ToastMsg>().expect("toast context");
let (name, is_dir, kind) = (entry.name.clone(), entry.is_dir, entry.kind);
let cb = Callback::new(move |_| {
if is_dir {
nav();
@@ -436,18 +422,17 @@ fn open_callback(
}
match preview_kind(kind) {
Some(k) => {
let full = join_path(&l.get().path, &name);
sp.set(Some((
let full = join_path(&loc.get().path, &name);
set_preview.set(Some((
PreviewTarget {
root_id,
path: full,
name: name.clone(),
size,
},
k,
)));
}
None => show(t, "No preview available — right-click to download"),
None => show(toast, "No preview available — right-click to download"),
}
});
(cb, on_ctx)
@@ -456,10 +441,9 @@ fn open_callback(
fn grid_view(
entries: &[Entry],
root_id: i64,
loc: &ReadSignal<Location>,
set_ctx: &WriteSignal<Option<CtxMenu>>,
toast: &ToastMsg,
set_preview: &WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
loc: ReadSignal<Location>,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
) -> impl IntoView {
view! {
<div class="entries-grid">
@@ -467,8 +451,7 @@ fn grid_view(
let icon = icon_for(e.kind, &e.name);
let name = e.name.clone();
let title = name.clone();
let (open_cb, on_ctx) =
open_callback(e, root_id, loc, set_ctx, toast, set_preview);
let (open_cb, on_ctx) = open_callback(e, root_id, loc, set_ctx, set_preview);
let oc1 = open_cb;
let oc2 = open_cb;
view! {
@@ -497,10 +480,9 @@ fn grid_view(
fn list_view(
entries: &[Entry],
root_id: i64,
loc: &ReadSignal<Location>,
set_ctx: &WriteSignal<Option<CtxMenu>>,
toast: &ToastMsg,
set_preview: &WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
loc: ReadSignal<Location>,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
) -> impl IntoView {
view! {
<div class="entries-list">
@@ -511,8 +493,7 @@ fn list_view(
let date = format_date(&e.mtime);
let is_dir = e.is_dir;
let title = name.clone();
let (open_cb, on_ctx) =
open_callback(e, root_id, loc, set_ctx, toast, set_preview);
let (open_cb, on_ctx) = open_callback(e, root_id, loc, set_ctx, set_preview);
let oc1 = open_cb;
let oc2 = open_cb;
view! {
@@ -596,7 +577,7 @@ fn CtxMenuView(
let is_rw = me_now
.as_ref()
.and_then(|m2| effective_root(&m2.roots, &loc_now))
.map(|r| r.mode == "rw")
.map(|r| r.mode.is_writable())
.unwrap_or(false);
let root_name = me_now
.as_ref()