admin share view, webdav escape as 403, cache policy, upload scratch guard

Admin share view. GET/DELETE /api/admin/shares lists every share on the
server with its creator and ends any of them. A share outlives the account
that made it: deactivating a user or taking a folder away leaves the link
serving, and nobody could sign in to end it. The Shares view now shows one
collapsible section per account for admins, marks deactivated accounts, and
offers delete-all per account. The listing hands out live tokens, so it is
admin-only and refuses share-token auth. README gains a Shares section that
spells out what does and does not end a share.

WebDAV. A request path or COPY/MOVE Destination that climbs out of the mount
answered 502 from dav-server's IllegalPath, which reads as a broken upstream.
It is now 403, same as a `..` that stays inside. dav_target used
DavPath::from_uri, which keeps the raw bytes, so deleting a percent-encoded
name over WebDAV never found its share and the link outlived the file. It
now decodes with DavPath::new.

Cache policy. File responses always send `Cache-Control: private, no-cache`,
also on 304 and on files with no validator. Without a directive a shared
cache may apply heuristic freshness to a response that depends on who asked.

Upload scratch guard. The `.upload-<token>` temp file is wrapped in a Drop
guard, so a client that closes the connection mid-part no longer leaves a
hidden file in the folder. Disarmed after the rename.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit679686a9be35508747dd8df2ff13c9f3080fa803
Parent6496b5c
18 files changed, 1164 insertions(+), 202 deletions(-)
▾MREADME.md
@@ -27,7 +27,7 @@ external services.
A writable share gives the link holder the same operations as a
read-write folder. An admin setting turns writable shares on or off
globally, and a read-only folder cannot be shared writable. Deleting, renaming, or moving an
item revokes its shares.
item revokes its shares. See [Shares](#shares).
- **WebDAV**: mount your folders, or a share, in a file manager. See
[WebDAV](#webdav).
- **UI**: light, dark, or system theme. English, German, and French.
@@ -99,6 +99,33 @@ Notes:
- The cache is disposable. You can delete the folder at any time; the server
makes the thumbnails again as they are needed.
## Shares
A share is a link to a **path**, not to the account that made it. Things that
end one: deleting, renaming or moving the item through the web UI (this also
revokes the shares on anything inside it), setting an expiry, deleting the
share under **Shares**, and deleting the creator's account.
Two admin actions do **not** end a share, so do them together with a revoke:
- **Deactivating an account.** Its logins and sessions stop immediately, but a
link that user made keeps serving — and a writable link keeps accepting
writes from whoever holds it. Delete the account, or the share, to close it.
- **Taking a folder away from a user.** A share stays open on a folder its
creator can no longer open.
Nor does a change the server did not make: a file moved or replaced over SSH,
or by any other process, keeps the shares that name its path — the server only
sees the operations it performs itself.
Under **Shares**, a signed-in user sees the links they made. An admin sees
every link on the server, in one section per account, and can end any of them —
including the links of an account that can no longer sign in, whose section is
marked as deactivated. That listing is admin-only because it is a list of
working credentials: every row's copy button produces the live link. Holding a
share token is access, so treat that view (and the tokens copied from it)
accordingly.
## WebDAV
Two mount points. Your permissions are the same as in the web UI.
▾Mapi-types/src/lib.rs
@@ -35,6 +35,9 @@ pub const DAV_SHARE: &str = "/dav-share";
/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
pub const ADMIN_USERS: &str = "/api/admin/users";
/// Admin view of every share on the server: `{ADMIN_SHARES}` and
/// `{ADMIN_SHARES}/{id}`. [`SHARES`] is the same data scoped to the caller.
pub const ADMIN_SHARES: &str = "/api/admin/shares";
pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
/// Pseudo root id every signed-in admin has on the files API: the whole
/// server root, read-only (the admin folder picker browses it). Real root
@@ -301,7 +304,7 @@ pub enum SearchEvent {
},
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, PartialEq)]
pub struct UserInfo {
pub id: i64,
pub name: String,
@@ -363,6 +366,23 @@ pub struct ShareInfo {
pub has_password: bool,
}
/// One share plus who owns it: GET `{ADMIN_SHARES}`.
///
/// Admin-only: it carries the full [`ShareInfo::token`], and a token is access.
/// Kept separate from [`ShareInfo`] because the public resolve route answers
/// with a `ShareInfo` to anonymous visitors.
#[derive(Serialize, Deserialize, Clone)]
pub struct AdminShare {
#[serde(flatten)]
pub share: ShareInfo,
pub creator_id: i64,
pub creator_name: String,
/// Whether the creator's account can still sign in. Deactivating an account
/// does not revoke its shares, so `false` marks a live link its owner can no
/// longer manage.
pub creator_active: bool,
}
/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
#[derive(Serialize, Deserialize, Clone)]
pub struct AdminUser {
▾Mserver/src/api/admin.rs
@@ -3,7 +3,9 @@
use std::sync::Arc;
use api_types::{AdminUser, CreateUser, Mode, OkResp, Root, RootInfo, Settings, UpdateUser};
use api_types::{
AdminShare, AdminUser, CreateUser, Mode, OkResp, Root, RootInfo, Settings, UpdateUser,
};
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
@@ -12,6 +14,7 @@ use crate::api::common::AdminUser as AdminGuard;
use crate::api::common::{
blocking, display_name, hash_password, validate_account_name, validate_password,
};
use crate::api::shares;
use crate::db::Db;
use crate::error::{ApiError, AppState};
use crate::fs;
@@ -239,6 +242,48 @@ pub async fn delete_user(
Ok(Json(OkResp {}))
}
// ---------------------------------------------------------------------------
// Shares
// ---------------------------------------------------------------------------
/// GET /api/admin/shares — every share on the server with its creator.
///
/// Answers with the full share tokens, which the admin view offers as copy
/// buttons. A token is access, so this route stays admin-only.
pub async fn list_shares(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
) -> Result<Json<Vec<AdminShare>>, ApiError> {
let rows = state.db.all_shares_with_creators().await?;
Ok(Json(
rows.iter()
.map(|r| AdminShare {
share: shares::share_info(&r.share, &state),
creator_id: r.share.creator_id,
creator_name: r.creator_name.clone(),
creator_active: r.creator_active,
})
.collect(),
))
}
/// DELETE /api/admin/shares/{id} — revoke a share whoever created it. The
/// user-facing `DELETE /api/shares/{id}` only touches the caller's own links.
pub async fn delete_share(
State(state): State<Arc<AppState>>,
_admin: AdminGuard,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
if !state.db.admin_delete_share(id).await? {
return Err(ApiError::localized(
StatusCode::NOT_FOUND,
"share not found",
"err_share_not_found",
));
}
Ok(Json(OkResp {}))
}
/// GET /api/admin/settings
pub async fn get_settings(
State(state): State<Arc<AppState>>,
▾Mserver/src/api/dav.rs
@@ -29,7 +29,7 @@ use axum::http::{Request, Response, StatusCode};
use axum::response::IntoResponse;
use bytes::{Buf, Bytes};
use dav_server::DavConfig;
use dav_server::davpath::DavPath;
use dav_server::davpath::{DavPath, ParseError};
use dav_server::fs::{
DavDirEntry, DavFile, DavMetaData, FsError, FsFuture, FsResult, FsStream, GuardedFileSystem,
OpenOptions, ReadDirMeta,
@@ -124,6 +124,36 @@ pub async fn share(State(state): State<Arc<AppState>>, req: Request<Body>) -> Re
serve(state, req, prefix, format!("share-{}", row.id), mount).await
}
/// Whether the request path still addresses this mount after `dav-server` has
/// normalized it: percent-decoded, `.` and `..` resolved, slashes merged.
///
/// Runs the same two steps as the handler, so it rejects nothing the handler
/// would accept. The other parse errors (`InvalidPath`, `ForbiddenPath`) are
/// left to the handler, which answers those with a 4xx of its own.
fn path_in_mount(path: &str, prefix: &str) -> bool {
// `OPTIONS *` has no leading slash. The handler answers it.
if !path.starts_with('/') {
return true;
}
!matches!(
DavPath::new(path).and_then(|mut p| p.set_prefix(prefix)),
Err(ParseError::PrefixMismatch)
)
}
/// The `Destination` header as a URL path, the way `dav-server`'s private
/// header parser reads it: a path as-is, a full URL (what mount clients send)
/// reduced to its path. `None` for a missing or unparseable header.
fn destination_path(headers: &HeaderMap) -> Option<String> {
let raw = headers.get("destination")?.to_str().ok()?;
if raw.starts_with('/') {
return Some(raw.to_string());
}
raw.parse::<axum::http::Uri>()
.ok()
.map(|u| u.path().to_string())
}
/// The token out of the *raw* URL path.
///
/// Not axum's decoded wildcard: `DavPath` keeps the raw path, and
@@ -149,6 +179,17 @@ async fn serve(
principal: String,
mount: Mount,
) -> Response<Body> {
// `dav-server` refuses an escaping path too, but with `502 Bad Gateway`
// (`DavError::IllegalPath`), which reads as a broken upstream. A `..` that
// stays inside the mount is already a `403`, so answer this the same way.
// The same for a COPY or MOVE `Destination`, which the handler normalizes
// the same way. A missing or malformed header stays with the handler.
let dest_in_mount =
destination_path(req.headers()).is_none_or(|dest| path_in_mount(&dest, &prefix));
if !path_in_mount(req.uri().path(), &prefix) || !dest_in_mount {
return StatusCode::FORBIDDEN.into_response();
}
// Resolved *before* the operation, while the item still exists: once
// DELETE or MOVE has run there is no path left to look a share up by.
let vacating = matches!(req.method().as_str(), "DELETE" | "MOVE");
@@ -193,7 +234,10 @@ fn dav_target(
prefix: &str,
req: &Request<Body>,
) -> Option<PathBuf> {
let mut path = DavPath::from_uri(req.uri()).ok()?;
// `DavPath::new`, not `from_uri`: the latter keeps the raw bytes, so an
// encoded path (`a%20b.txt`) would never resolve and the share would
// outlive the file it named.
let mut path = DavPath::new(req.uri().path()).ok()?;
path.set_prefix(prefix).ok()?;
let (root, rel) = item(&path, mount).ok()?;
// `resolve_entry`, matching the operations this is predicting. Following
▾Mserver/src/api/files.rs
@@ -10,7 +10,7 @@
//! - `POST /api/files/{root_id}/{*path}` (multipart) — upload into the dir
use std::io::{self, Read};
use std::path::Component;
use std::path::{Component, PathBuf};
use std::sync::Arc;
use axum::Json;
@@ -139,6 +139,13 @@ fn ims_header(headers: &axum::http::HeaderMap) -> Option<String> {
.map(str::to_string)
}
/// Caching policy for a served file.
///
/// `private` because the response depends on who asked. `no-cache`, not
/// `no-store`, so a client can revalidate a large media file and get a `304`
/// instead of re-downloading it.
const FILE_CACHE: &str = "private, no-cache";
/// An mtime (unix seconds) as an HTTP-date, the `Last-Modified` format.
/// None for an unknown mtime (0) and for a file written in the last two
/// seconds: whole-second dates cannot tell two writes in one second apart.
@@ -276,6 +283,7 @@ async fn download(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, fmt.mime())
.header(header::CONTENT_DISPOSITION, disp)
.header(header::CACHE_CONTROL, FILE_CACHE)
.body(body)
.map_err(|e| {
ApiError::new(
@@ -357,6 +365,7 @@ async fn content(
header::CONTENT_TYPE,
"text/plain; charset=utf-8".to_string(),
),
(header::CACHE_CONTROL, FILE_CACHE.to_string()),
(
axum::http::HeaderName::from_static("x-file-mtime"),
mtime.to_string(),
@@ -504,9 +513,14 @@ async fn file_response(
ims: Option<&str>,
) -> Result<Response, ApiError> {
let last_modified = http_date(mtime);
// A revalidating client gets the empty 304 instead of the whole file.
// A revalidating client gets the empty 304 instead of the whole file. The
// policy is repeated on it, so the stored copy does not lose the directive.
if last_modified.is_some() && unmodified_since(ims, mtime) {
return Ok(StatusCode::NOT_MODIFIED.into_response());
return Ok((
StatusCode::NOT_MODIFIED,
[(header::CACHE_CONTROL, FILE_CACHE)],
)
.into_response());
}
let mime = mime_guess::from_path(full)
.first_or_octet_stream()
@@ -539,13 +553,13 @@ async fn file_response(
})
.header(header::CONTENT_DISPOSITION, disp)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, end - start);
.header(header::CONTENT_LENGTH, end - start)
// Always sent, validator or not: with no directive a cache may apply
// heuristic freshness to a response that depends on who asked.
.header(header::CACHE_CONTROL, FILE_CACHE);
if let Some(lm) = last_modified {
// `no-cache` keeps browsers from serving a heuristically fresh copy.
// They revalidate instead, and get the 304 above.
res = res
.header(header::LAST_MODIFIED, lm)
.header(header::CACHE_CONTROL, "no-cache");
// The validator the `no-cache` above revalidates against.
res = res.header(header::LAST_MODIFIED, lm);
}
if partial {
res = res.header(
@@ -1090,8 +1104,8 @@ async fn upload(
// Stream to a temp file in the same directory, then rename into place.
let suffix = crate::auth::random_token();
let tmp = parent.join(format!(".upload-{suffix}"));
let tmp_file = tokio::fs::File::create(&tmp).await.map_err(|_| {
let tmp = Scratch(parent.join(format!(".upload-{suffix}")));
let tmp_file = tokio::fs::File::create(&tmp.0).await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
@@ -1120,25 +1134,29 @@ async fn upload(
}
};
if write_failed {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"could not save the file",
"err_save_failed",
));
}
let tmp2 = tmp.clone();
let tmp2 = tmp.0.clone();
let target2 = target.clone();
let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp2, &target2)).await;
// 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))
.await
.map_err(io::Error::other)
.and_then(|r| r);
if let Err(e) = renamed {
let _ = tokio::fs::remove_file(&tmp).await;
tracing::warn!(error = %e, "rename failed during upload");
tracing::warn!(error = %e, path = %target.display(), "rename failed during upload");
return Err(ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
));
}
tmp.disarm();
uploaded += 1;
}
@@ -1160,6 +1178,32 @@ async fn upload(
Ok(Json(UploadResp { uploaded }).into_response())
}
/// 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.
struct Scratch(PathBuf);
impl Scratch {
/// The scratch file is now the uploaded file: leave it alone.
fn disarm(self) {
std::mem::forget(self);
}
}
impl Drop for Scratch {
fn drop(&mut self) {
// Plain blocking unlink: `Drop` can run during runtime shutdown, where
// `tokio::spawn` panics. One unlink cannot block meaningfully.
if let Err(e) = std::fs::remove_file(&self.0)
&& e.kind() != io::ErrorKind::NotFound
{
tracing::warn!(error = %e, path = %self.0.display(), "could not remove upload scratch file");
}
}
}
fn parse_boundary(content_type: &str) -> Option<String> {
content_type
.split(';')
▾Mserver/src/api/mod.rs
@@ -1,8 +1,8 @@
use std::sync::Arc;
use api_types::{
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, DAV, DAV_SHARE,
FILES, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, DAV,
DAV_SHARE, FILES, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -102,6 +102,7 @@ pub fn router(state: Arc<AppState>) -> Router {
let share_token = format!("{SHARE}/{{token}}");
let share_unlock = format!("{SHARE}/{{token}}{SHARE_UNLOCK_SUFFIX}");
let admin_user_id = format!("{ADMIN_USERS}/{{id}}");
let admin_share_id = format!("{ADMIN_SHARES}/{{id}}");
// A wildcard needs something to capture, so `/dav/` gets its own pattern:
// mount clients ask for it with the trailing slash, which matches neither
// the bare path nor `{*path}`.
@@ -130,6 +131,8 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(ADMIN_USERS, post(admin::create_user))
.route(&admin_user_id, put(admin::update_user))
.route(&admin_user_id, delete(admin::delete_user))
.route(ADMIN_SHARES, get(admin::list_shares))
.route(&admin_share_id, delete(admin::delete_share))
.route(ADMIN_SETTINGS, get(admin::get_settings))
.route(ADMIN_SETTINGS, put(admin::update_settings))
// `any`, not a method filter: WebDAV's verbs (PROPFIND, MKCOL, MOVE, …)
▾Mserver/src/api/shares.rs
@@ -28,7 +28,7 @@ use crate::error::{ApiError, AppState};
use crate::fs;
/// Shared JSON shape for a share (list / create / public resolve).
fn share_info(row: &ShareRow, state: &AppState) -> ShareInfo {
pub(crate) fn share_info(row: &ShareRow, state: &AppState) -> ShareInfo {
ShareInfo {
id: row.id,
token: row.token.clone(),
▾Mserver/src/db.rs
@@ -80,6 +80,16 @@ impl ShareRow {
}
}
/// A [`ShareRow`] together with the account that created it.
#[derive(Debug, Clone)]
pub struct ShareWithCreator {
pub share: ShareRow,
pub creator_name: String,
/// Whether that account can still sign in. Deactivating an account leaves
/// its shares live.
pub creator_active: bool,
}
/// Every query can fail, and every caller decides what to do about it.
///
/// Earlier versions swallowed read errors and returned a default (an empty
@@ -664,6 +674,38 @@ impl Db {
Ok(n > 0)
}
/// Every share on the server with its creator. Grouped by account name,
/// newest link within an account first.
///
/// The join cannot miss: `shares.creator_id` cascades on delete, so a share
/// never outlives the account that made it.
pub async fn all_shares_with_creators(&self) -> DbResult<Vec<ShareWithCreator>> {
let c = self.0.lock().await;
// Columns 0..8 are `map_share`'s order, unchanged from `user_shares`.
let sql = "SELECT s.id, s.token, s.creator_id, s.target, s.is_file, s.mode,
s.created_at, s.expires_at, s.password_hash,
u.name, u.active != 0
FROM shares s
JOIN users u ON u.id = s.creator_id
ORDER BY u.name COLLATE NOCASE, s.id DESC";
let mut stmt = c.prepare_cached(sql)?;
let rows = stmt.query_map([], |r| {
Ok(ShareWithCreator {
share: map_share(r)?,
creator_name: r.get(9)?,
creator_active: r.get(10)?,
})
})?;
rows.collect()
}
/// Revoke a share whoever created it. The owner-scoped
/// [`Self::delete_share`] is what the user-facing API uses.
pub async fn admin_delete_share(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
Ok(c.execute("DELETE FROM shares WHERE id = ?1", [id])? > 0)
}
// ---------- settings ----------
/// Folders excluded from search, as paths relative to the server root.
▾Mserver/tests/api_admin.rs
@@ -21,6 +21,15 @@ async fn admin_routes_require_admin() {
anon.get("/api/admin/settings").await.status,
StatusCode::UNAUTHORIZED
);
assert_eq!(
anon.get("/api/admin/shares").await.status,
StatusCode::UNAUTHORIZED,
"the listing of every share link on the server is not public"
);
assert_eq!(
anon.delete("/api/admin/shares/1").await.status,
StatusCode::UNAUTHORIZED
);
// Non-admin session → 403.
create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await;
@@ -29,6 +38,16 @@ async fn admin_routes_require_admin() {
bob.get("/api/admin/users").await.status,
StatusCode::FORBIDDEN
);
assert_eq!(
bob.get("/api/admin/shares").await.status,
StatusCode::FORBIDDEN,
"a plain user must not read the links of others"
);
assert_eq!(
bob.delete("/api/admin/shares/1").await.status,
StatusCode::FORBIDDEN,
"nor revoke them"
);
assert_eq!(
bob.put_json(
"/api/admin/settings",
▾Mserver/tests/api_dav.rs
@@ -238,6 +238,97 @@ async fn a_mount_cannot_leave_its_roots() {
}
}
#[tokio::test]
async fn a_path_that_climbs_out_of_the_mount_is_not_a_server_error() {
let env = Env::new().await;
// Not `admin_dav`: the share below needs the client too, so both halves
// are set up here.
let admin = env.admin().await;
let auth = basic("admin", "admin1234");
let seg = root_seg(&env);
// `a_mount_cannot_leave_its_roots` covers a `..` that stays inside the
// mount. This is the other case: enough `..` to climb out entirely.
// `dav-server` answers that with `DavError::IllegalPath`, a `502` that
// reads as a broken upstream. The sideways case is a 4xx, so this must be.
for path in [
"/dav/%2e%2e/etc/passwd",
"/dav/../etc/passwd",
&format!("/dav/{seg}/docs/../../../outside.txt"),
] {
let r = dav(&env, "PROPFIND", path, Some(&auth), b"").await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}: {}", r.status);
}
// One `..` short of the escape: still inside the mount, so it gets the
// ordinary answer for a folder that is not mounted.
let r = dav(
&env,
"PROPFIND",
&format!("/dav/{seg}/docs/../../x"),
Some(&auth),
b"",
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// What the normalization itself rejects keeps the status it had: an encoded
// slash is a malformed segment, not an escape attempt.
let r = dav(
&env,
"GET",
"/dav/docs/..%2F..%2Foutside.txt",
Some(&auth),
b"",
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// The `Destination` of a COPY or MOVE is a path too, parsed the same way.
// As a bare path, and as the full URL a mount client sends.
for dest in [
"/etc/outside.txt",
"http://localhost/dav/../etc/outside.txt",
] {
for verb in ["MOVE", "COPY"] {
let r = dav_with(
&env,
verb,
&format!("/dav/{seg}/docs/inner/hello.txt"),
Some(&auth),
&[("destination", dest)],
b"",
)
.await;
assert_eq!(
r.status,
StatusCode::FORBIDDEN,
"{verb} to {dest}: {}",
r.status
);
}
}
assert!(
env.file("docs/inner/hello.txt").exists(),
"the source is untouched"
);
// A share mount is a mount point too, and it is the one strangers reach.
let (token, _) = share(&admin, "docs", false, None).await;
let r = dav(
&env,
"PROPFIND",
&format!("/dav-share/{token}/%2e%2e"),
None,
b"",
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// The mount itself still works, so this is a refusal and not a breakage.
let r = dav(&env, "PROPFIND", &format!("/dav-share/{token}/"), None, b"").await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
}
#[tokio::test]
async fn a_read_only_root_refuses_every_write() {
let env = Env::new().await;
@@ -567,6 +658,31 @@ async fn deleting_a_shared_path_over_webdav_revokes_the_share() {
// A share pointing at a path that no longer exists must not linger.
let r = admin.get(&format!("/api/share/{token}")).await;
assert_eq!(r.status, StatusCode::NOT_FOUND, "{}", r.text());
// The same for a name that has to be percent-encoded: the lookup decodes
// the URL like the delete does, or the link would outlive the file.
let r = dav(
&env,
"PUT",
&format!("/dav/{seg}/docs/a%20b.txt"),
Some(&auth),
b"hi",
)
.await;
assert!(r.status.is_success(), "{} {}", r.status, r.text());
let (token, _) = share(&admin, "docs/a b.txt", false, None).await;
let r = dav(
&env,
"DELETE",
&format!("/dav/{seg}/docs/a%20b.txt"),
Some(&auth),
b"",
)
.await;
assert!(r.status.is_success(), "{} {}", r.status, r.text());
let r = admin.get(&format!("/api/share/{token}")).await;
assert_eq!(r.status, StatusCode::NOT_FOUND, "{}", r.text());
}
// ---------------------------------------------------------------------------
▾Mserver/tests/api_files.rs
@@ -766,6 +766,88 @@ 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");
}
/// 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.
async fn upload_stopped_mid_part(env: &Env, admin: &Client) -> StatusCode {
let mut body: Vec<u8> = Vec::new();
body.extend_from_slice(
b"--B\r\nContent-Disposition: form-data; name=\"interrupted.txt\"\r\n\r\n",
);
body.extend_from_slice(&vec![b'x'; 300 * 1024]);
let stream = futures_util::stream::unfold(body, |mut rest| async move {
if rest.len() > 1024 {
let n = rest.len() / 2;
let chunk: Vec<u8> = rest.drain(..n).collect();
Some((
Ok::<_, std::io::Error>(axum::body::Bytes::from(chunk)),
rest,
))
} else {
None // EOF: the terminating boundary never arrives
}
});
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 _ = http_body_util::BodyExt::collect(res.into_body()).await;
status
}
/// Every entry name in the server root, hidden ones included.
fn entries(env: &Env) -> Vec<String> {
std::fs::read_dir(env.root.path())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect()
}
/// An upload is streamed to `.upload-<token>` and renamed into place. A part
/// that never reaches the rename must take the scratch file with it. Nothing
/// ever names that file again, and listings show it.
#[tokio::test]
async fn an_interrupted_upload_leaves_no_scratch_file() {
let env = Env::new().await;
let admin = env.admin().await;
let status = upload_stopped_mid_part(&env, &admin).await;
assert!(
status.is_client_error(),
"expected a rejection, got {status}"
);
assert!(
!entries(&env).iter().any(|n| n.starts_with(".upload-")),
"scratch file left behind: {:?}",
entries(&env)
);
assert!(!env.file("interrupted.txt").exists());
// The same assertion after a completed upload, so a guard that never
// disarms cannot pass this test by accident.
let r = admin
.post_multipart(&root_path(""), &[("finished.txt", b"whole")], "")
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(std::fs::read(env.file("finished.txt")).unwrap(), b"whole");
assert!(
!entries(&env).iter().any(|n| n.starts_with(".upload-")),
"scratch file left after a completed upload: {:?}",
entries(&env)
);
}
#[tokio::test]
async fn read_only_root_blocks_writes_but_allows_reads() {
let env = Env::new().await;
@@ -1034,6 +1116,13 @@ async fn download_revalidates_with_last_modified() {
let r = admin.get(&path).await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.header("last-modified").is_none());
// No validator here, so the policy matters more: with no Cache-Control a
// shared cache may apply heuristic freshness.
assert_eq!(
r.header("cache-control").as_deref(),
Some("private, no-cache"),
"a file response always carries a caching policy"
);
// Backdate the file so the validator appears.
let f = std::fs::File::options().write(true).open(&file).unwrap();
@@ -1043,7 +1132,10 @@ async fn download_revalidates_with_last_modified() {
.unwrap();
let r = admin.get(&path).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.header("cache-control").as_deref(), Some("no-cache"));
assert_eq!(
r.header("cache-control").as_deref(),
Some("private, no-cache")
);
let lm = r.header("last-modified").expect("Last-Modified header");
let r = admin
@@ -1056,6 +1148,11 @@ async fn download_revalidates_with_last_modified() {
.await;
assert_eq!(r.status, StatusCode::NOT_MODIFIED);
assert!(r.body.is_empty());
// The refresh repeats the policy, so the stored entry does not lose it.
assert_eq!(
r.header("cache-control").as_deref(),
Some("private, no-cache")
);
}
// ---------------------------------------------------------------------------
▾Mserver/tests/api_shares.rs
@@ -839,3 +839,156 @@ async fn a_subfolder_share_by_empty_path_is_unaffected() {
assert_eq!(s["target"], "docs");
assert_eq!(s["name"], "docs");
}
// ---------------------------------------------------------------------------
// The admin's share view (GET/DELETE /api/admin/shares)
// ---------------------------------------------------------------------------
/// Create a share as `who`, from their first root.
async fn share_as(who: &Client, path: &str) -> serde_json::Value {
let root_id = who.get("/api/auth/me").await.json()["roots"][0]["id"].clone();
let r = who
.post_json(
"/api/shares",
&json!({"root_id": root_id, "path": path, "writable": false}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "share: {}", r.text());
r.json()
}
#[tokio::test]
async fn an_admin_sees_every_share_with_its_creator() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await;
create_user(&admin, "carol", "carolpass123", &[("src", "rw")]).await;
let bob = login(&env, "bob", "bobpass123").await;
let carol = login(&env, "carol", "carolpass123").await;
let bob_share = share_as(&bob, "a.txt").await;
share_as(&carol, "").await;
let own = share(&admin, "notes.md", false, None).await;
let r = admin.get("/api/admin/shares").await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let all = r.json();
let rows = all.as_array().unwrap();
assert_eq!(rows.len(), 3, "{all}");
// The real token, not a redacted one: the copy button must produce a link
// that works.
let row = rows
.iter()
.find(|s| s["id"] == bob_share["id"])
.expect("bob's share in the admin list");
assert_eq!(row["creator_name"], "bob");
assert!(row["creator_active"].as_bool().unwrap());
assert_eq!(row["token"], bob_share["token"]);
assert_eq!(row["target"], "docs/a.txt");
assert_eq!(row["name"], "a.txt");
let by_name = |n: &str| {
rows.iter()
.filter(|s| s["creator_name"] == json!(n))
.count()
};
assert_eq!(by_name("bob"), 1);
assert_eq!(by_name("carol"), 1);
assert_eq!(by_name("admin"), 1);
assert_eq!(
rows.iter().find(|s| s["id"] == own["id"]).unwrap()["creator_name"],
"admin"
);
// The viewer's own list is unchanged: scoping is per creator, not per
// target.
let mine = bob.get("/api/shares").await.json();
assert_eq!(mine.as_array().unwrap().len(), 1, "{mine}");
assert_eq!(mine[0]["id"], bob_share["id"]);
}
/// A deactivated account keeps its links, and nobody can sign in to end them.
#[tokio::test]
async fn an_admin_can_end_a_share_the_creator_can_no_longer_reach() {
let env = Env::new().await;
let admin = env.admin().await;
let created = create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await;
let bob = login(&env, "bob", "bobpass123").await;
let s = share_as(&bob, "a.txt").await;
let (id, token) = (s["id"].as_i64().unwrap(), s["token"].as_str().unwrap());
let anon = Client::new(env.app.clone());
assert_eq!(
anon.get(&format!("/api/share/{token}")).await.status,
StatusCode::OK,
"the link works before anything changes"
);
// Suspend bob and take his folder. The link survives that (a known gap), so
// the admin view must flag it and be able to close it.
admin
.put_json(
&format!("/api/admin/users/{}", created["id"]),
&json!({ "active": false, "roots": [] }),
)
.await;
let r = admin.get("/api/admin/shares").await.json();
let row = r.as_array().unwrap().first().unwrap();
assert_eq!(row["creator_name"], "bob");
assert!(
!row["creator_active"].as_bool().unwrap(),
"a deactivated account's live share is flagged: {r}"
);
assert_eq!(
anon.get(&format!("/api/share/{token}")).await.status,
StatusCode::OK,
"still open until someone ends it"
);
// The admin ends it. bob cannot: he can no longer sign in.
let r = admin.delete(&format!("/api/admin/shares/{id}")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
anon.get(&format!("/api/share/{token}")).await.status,
StatusCode::NOT_FOUND,
"the link is dead"
);
assert!(
admin
.get("/api/admin/shares")
.await
.json()
.as_array()
.unwrap()
.is_empty()
);
// An id that is not a share is a 404, not a silent success.
assert_eq!(
admin.delete("/api/admin/shares/4242").await.status,
StatusCode::NOT_FOUND
);
}
/// The admin listing hands out every token, so a share token must never read
/// it, even one created by an admin session.
#[tokio::test]
async fn a_share_token_cannot_read_the_admin_listing() {
let env = Env::new().await;
let admin = env.admin().await;
let s = share(&admin, "docs", false, None).await;
let token = s["token"].as_str().unwrap().to_string();
let anon = Client::new(env.app.clone());
let r = anon.get(&format!("/api/admin/shares?share={token}")).await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
assert!(
anon.delete(&format!("/api/admin/shares/1?share={token}"))
.await
.status
.is_client_error()
);
// The token still opens its own share.
assert_eq!(
anon.get(&format!("/api/share/{token}")).await.status,
StatusCode::OK
);
}
▾Mweb/app.css
@@ -1613,6 +1613,88 @@ button:disabled {
flex-shrink: 0;
}
/* Sections of the share management view: the viewer's own links and, for an
admin, one section per account. Native <details>/<summary>. */
.share-groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.share-group {
border: 1px solid var(--border);
background: var(--panel);
}
.share-group-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
/* The caret below stands in for the disclosure triangle. */
list-style: none;
}
.share-group-head::-webkit-details-marker {
display: none;
}
.share-group-head:hover {
background: color-mix(in srgb, var(--accent) 6%, var(--panel));
}
.share-group-head:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.share-caret {
width: 16px;
height: 16px;
flex: none;
transition: rotate 0.15s ease;
}
.share-group[open] .share-caret {
rotate: 90deg;
}
.share-group-count {
font-size: 12px;
font-weight: 400;
}
/* A live link whose account cannot sign in any more. */
.share-group-warn {
font-size: 12px;
font-weight: 400;
color: var(--danger);
}
.share-group-del {
margin-left: auto;
}
/* Rows inside a section read as one block: the border moves from each row to
the section. */
.share-group .share-list {
gap: 0;
border-top: 1px solid var(--border);
}
.share-group .share-item {
border: 0;
border-top: 1px solid var(--border);
}
.share-group .share-item:first-child {
border-top: 0;
}
/* ------------------------------------------------------------------ */
/* Admin (milestone 7) */
/* ------------------------------------------------------------------ */
▾Mweb/src/api.rs
@@ -14,13 +14,14 @@ use wasm_bindgen_futures::JsFuture;
use api_types::{
ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_MKDIR, ACTION_PREVIEW,
ACTION_THUMB, ADMIN_SETTINGS, 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_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,
};
pub use api_types::{
AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo, UserInfo,
AdminShare, AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo,
UserInfo,
};
#[derive(Debug, thiserror::Error)]
@@ -521,6 +522,16 @@ pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp,
request("DELETE", format!("{SHARES}/{id}"), None::<()>)
}
/// Admin only: every share on the server with its creator.
pub fn list_all_shares() -> impl std::future::Future<Output = Result<Vec<AdminShare>, ApiError>> {
request("GET", ADMIN_SHARES.to_string(), None::<()>)
}
/// Admin only: end a share whoever created it.
pub fn admin_delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{ADMIN_SHARES}/{id}"), None::<()>)
}
/// Public: resolve a share (no session required). A password-protected
/// share answers 401 until [`unlock_share`] has run in this browser.
pub fn resolve_share(
▾Mweb/src/i18n.rs
@@ -187,6 +187,8 @@ i18n_keys! {
ADD_FOLDER = "add_folder" => "Add folder…",
ADD_HERE = "add_here" => "Add this folder",
ADMINISTRATOR = "administrator" => "Administrator",
ALL_SHARES = "all_shares" => "All shares",
ALL_SHARES_HINT = "all_shares_hint" => "Every link on this server, grouped by the account that created it.",
ALLOW_EDITING = "allow_editing" => "Allow editing (read-write)",
ALLOW_RW_SHARES = "allow_rw_shares" => "Allow writable shares",
ALLOW_RW_SHARES_DESC = "allow_rw_shares_desc" => "Let users create read-write share links. Off by default.",
@@ -213,6 +215,7 @@ i18n_keys! {
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.",
DELETE_ALL_TITLE = "delete_all_title" => "Delete all shares?",
DELETE_ALL_USER_MSG = "delete_all_user_msg" => "Delete every share link of “{}”? Anyone holding them loses access. This cannot be undone.",
DELETE_FOLDER_MSG = "delete_folder_msg" => "Delete folder “{}” and everything inside it?\nThis cannot be undone.",
DELETE_MSG = "delete_msg" => "Delete “{}”?\nThis cannot be undone.",
DELETE_N_MSG = "delete_n_msg" => "Delete {} items?\nThis cannot be undone.",
@@ -445,6 +448,7 @@ i18n_keys! {
SHARE_LOCKED_TITLE = "share_locked_title" => "Password required",
SHARE_NOT_FOUND = "share_not_found" => "Share not found",
SHARE_NOT_FOUND_MSG = "share_not_found_msg" => "This link is invalid or the share was removed.",
SHARE_OWNER_DISABLED = "share_owner_disabled" => "deactivated account",
SHARE_PASSWORD_HINT = "share_password_hint" => "Optional. Visitors must enter it before the share opens, at least 8 characters.",
SHARE_PERM_RO = "share_perm_ro" => "Anyone with this link can read “{}”.",
SHARE_PERM_RW = "share_perm_rw" => "Anyone with this link can read and write “{}”.",
@@ -490,6 +494,11 @@ const DE: &[(&str, &str)] = &[
("add_folder", "Ordner hinzufügen…"),
("add_here", "Diesen Ordner hinzufügen"),
("administrator", "Administrator"),
("all_shares", "Alle Freigaben"),
(
"all_shares_hint",
"Jeder Link auf diesem Server, gruppiert nach dem Konto, das ihn erstellt hat.",
),
("allow_editing", "Bearbeitung erlauben (lesen/schreiben)"),
("allow_rw_shares", "Schreibbare Freigaben erlauben"),
(
@@ -522,6 +531,10 @@ const DE: &[(&str, &str)] = &[
"Alle {} Freigabelinks löschen? Jeder, der sie besitzt, verliert den Zugriff. Das kann nicht rückgängig gemacht werden.",
),
("delete_all_title", "Alle Freigaben löschen?"),
(
"delete_all_user_msg",
"Alle Freigabelinks von „{}“ löschen? Jeder, der sie besitzt, verliert den Zugriff. Das kann nicht rückgängig gemacht werden.",
),
(
"delete_folder_msg",
"Ordner „{}“ mit allem Inhalt löschen?\nDas kann nicht rückgängig gemacht werden.",
@@ -913,6 +926,7 @@ const DE: &[(&str, &str)] = &[
"share_not_found_msg",
"Dieser Link ist ungültig oder die Freigabe wurde entfernt.",
),
("share_owner_disabled", "deaktiviertes Konto"),
(
"share_password_hint",
"Optional. Besucher müssen es eingeben, bevor die Freigabe geöffnet wird, mindestens 8 Zeichen.",
@@ -982,6 +996,11 @@ const FR: &[(&str, &str)] = &[
("add_folder", "Ajouter un dossier…"),
("add_here", "Ajouter ce dossier"),
("administrator", "Administrateur"),
("all_shares", "Tous les partages"),
(
"all_shares_hint",
"Tous les liens de ce serveur, groupés par le compte qui les a créés.",
),
(
"allow_editing",
"Autoriser la modification (lecture-écriture)",
@@ -1020,6 +1039,10 @@ const FR: &[(&str, &str)] = &[
"Supprimer les {} liens de partage ? Toute personne les possédant perdra l'accès. Cette action est irréversible.",
),
("delete_all_title", "Supprimer tous les partages ?"),
(
"delete_all_user_msg",
"Supprimer tous les liens de partage de « {} » ? Toute personne les possédant perdra l'accès. Cette action est irréversible.",
),
(
"delete_folder_msg",
"Supprimer le dossier « {} » et tout son contenu ?\nCette action est irréversible.",
@@ -1402,6 +1425,7 @@ const FR: &[(&str, &str)] = &[
"share_not_found_msg",
"Ce lien est invalide ou le partage a été supprimé.",
),
("share_owner_disabled", "compte désactivé"),
(
"share_password_hint",
"Facultatif. Les visiteurs doivent le saisir avant d'ouvrir le partage, au moins 8 caractères.",
▾Mweb/src/icons.rs
@@ -91,6 +91,8 @@ pub enum IconName {
ThemeDark,
/// material-symbols:more-vert
MoreVert,
/// material-symbols:chevron-right
ChevronRight,
/// material-symbols:arrow-upward
SortAsc,
/// material-symbols:arrow-downward
@@ -218,6 +220,9 @@ impl IconName {
Self::MoreVert => {
r#"<path fill="currentColor" d="M12 20q-.825 0-1.412-.587T10 18t.588-1.412T12 16t1.413.588T14 18t-.587 1.413T12 20m0-6q-.825 0-1.412-.587T10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14m0-6q-.825 0-1.412-.587T10 6t.588-1.412T12 4t1.413.588T14 6t-.587 1.413T12 8"/>"#
}
Self::ChevronRight => {
r#"<path fill="currentColor" d="M12.6 12L8 7.4L9.4 6l6 6l-6 6L8 16.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/views/shares.rs
@@ -4,8 +4,11 @@ use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, ShareInfo};
use crate::components::icon::Icon;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use crate::api::{self, AdminShare, Me, ShareInfo, UserInfo};
use crate::components::icon::icon_svg;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
@@ -145,7 +148,7 @@ pub fn ShareDialog(
title={i18n::tr(i18n::k::COPY_LINK)}
on:click=move |_| copy_to_clipboard(&link, toast)
>
<Icon name=IconName::Copy class="ic-btn".to_string()/>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(i18n::k::COPY)}
</button>
</div>
@@ -165,7 +168,7 @@ pub fn ShareDialog(
title={i18n::tr(i18n::k::COPY_DAV_LINK)}
on:click=move |_| copy_to_clipboard(&dav_link, toast)
>
<Icon name=IconName::Copy class="ic-btn".to_string()/>
{icon_svg(IconName::Copy, "ic-btn")}
"WebDAV"
</button>
</div>
@@ -311,71 +314,261 @@ pub fn ShareDialog(
}
// ---------------------------------------------------------------------------
// "Your shares" manage view
// Share management view
//
// A plain user sees their own links. An admin sees every link on the server,
// one collapsible section per account. A share outlives the account that made
// it (README "Shares"), so without this view nobody could end those links.
// ---------------------------------------------------------------------------
// Both endpoints load into `AdminShare`, so grouping and rows never branch on
// which one answered. The own list has no creator, so the viewer fills it in.
/// One collapsible section: an account and its links.
struct Group {
id: i64,
name: String,
active: bool,
/// The viewer's own links: rendered first, and open by default.
mine: bool,
shares: Vec<ShareInfo>,
}
/// Bucket rows by creator. The map key sorts the sections: the viewer's own
/// first, the rest by account name. Non-admins only ever get their own group.
/// Rows are pushed in the order the server sent them, so newest stays first.
fn group_rows(rows: &[AdminShare], my_id: i64) -> Vec<Group> {
let mut groups: BTreeMap<(bool, String, i64), Group> = BTreeMap::new();
for r in rows {
let mine = r.creator_id == my_id;
groups
.entry((!mine, r.creator_name.to_lowercase(), r.creator_id))
.or_insert_with(|| Group {
id: r.creator_id,
name: r.creator_name.clone(),
active: r.creator_active,
mine,
shares: Vec::new(),
})
.shares
.push(r.share.clone());
}
groups.into_values().collect()
}
/// A bulk deletion that is waiting for the answer to the confirmation dialog.
#[derive(Clone, PartialEq)]
struct Pending {
ids: Vec<i64>,
/// The account the question names; `None` asks about everything listed.
who: Option<String>,
}
/// End one link. Only the admin route can end a link the viewer does not own,
/// so an admin always takes it and everyone else always takes the user route.
async fn end_share(is_admin: bool, id: i64) -> bool {
if is_admin {
api::admin_delete_share(id).await
} else {
api::delete_share(id).await
}
.is_ok()
}
/// One share row: name, meta line, copy buttons, delete. Every section renders
/// its rows through this, so an admin sees exactly what the owner sees.
fn share_row(s: &ShareInfo, toast: ToastMsg, del: Callback<i64>) -> AnyView {
let link = share_url(&s.token);
let dav_link = dav_share_url(&s.token);
let is_file = s.is_file;
let glyph = if is_file {
IconName::File
} else {
IconName::Folder
};
let mut meta = format!(
"{} · {}",
s.target,
if s.writable {
i18n::t(i18n::k::MODE_RW)
} else {
i18n::t(i18n::k::MODE_RO)
}
);
if let Some(e) = &s.expires_at {
meta.push_str(&format!(
" · {}",
i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e))
));
}
meta.push_str(&format!(
" · {}",
i18n::t_fmt(i18n::k::META_CREATED, &format_date(&s.created_at))
));
if s.has_password {
meta.push_str(&format!(" · {}", i18n::t(i18n::k::SHARE_PROTECTED)));
}
let name = s.name.clone();
let id = s.id;
view! {
<div class="share-item">
<div class="share-item-main">
<div class="share-item-name">
{icon_svg(glyph, "ic-row")}
<span>{name}</span>
</div>
<div class="share-item-meta muted">{meta}</div>
</div>
<div class="share-item-actions">
<button
class="btn btn-sm"
title={i18n::tr(i18n::k::COPY_LINK)}
on:click=move |_| copy_to_clipboard(&link, toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(i18n::k::COPY)}
</button>
// Folder shares only: a file share has no collection to mount.
<Show when=move || !is_file>
<button
class="btn btn-sm"
title={i18n::tr(i18n::k::COPY_DAV_LINK)}
on:click={
let dav = dav_link.clone();
move |_| copy_to_clipboard(&dav, toast)
}
>
{icon_svg(IconName::Copy, "ic-btn")}
"WebDAV"
</button>
</Show>
<button
class="btn btn-sm btn-danger"
title={i18n::tr(i18n::k::DELETE_SHARE)}
on:click=move |_| del.run(id)
>
{icon_svg(IconName::Delete, "ic-btn")}
{i18n::t(i18n::k::DELETE)}
</button>
</div>
</div>
}
.into_view()
.into_any()
}
#[component]
pub fn SharesView() -> impl IntoView {
pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (shares, set_shares) = signal(Option::<Vec<ShareInfo>>::None);
// One extraction of the signed-in user; everything below reads a field.
// The shell renders this view only behind a signed-in `me`.
let user: Memo<UserInfo> = Memo::new(move |_| {
me.with(|m| m.as_ref().and_then(|m| m.user.clone()))
.expect("SharesView needs a signed-in user")
});
let admin = move || user.with(|u| u.is_admin);
let my_id = move || user.with(|u| u.id);
// None until the first load answers, so "loading" and "no shares" stay
// distinguishable.
let (rows, set_rows) = signal(Option::<Vec<AdminShare>>::None);
// Set when the list could not be loaded; the error card offers a retry.
let (load_err, set_load_err) = signal(Option::<String>::None);
let (confirm_all, set_confirm_all) = signal(false);
let (busy_all, set_busy_all) = signal(false);
let (confirm, set_confirm) = signal(None::<Pending>);
let (busy, set_busy) = signal(false);
// Which sections are open, by account. Kept out of the DOM: the listing is
// rebuilt on every delete, and a section must not snap shut under the user.
let (open, set_open) = signal(HashMap::<i64, bool>::new());
let load = move || {
set_load_err.set(None);
// `try_get`, not `get`: a bulk delete calls this after its awaits, when
// the view may be gone. Reading a disposed memo panics and kills the app.
let Some(u) = user.try_get() else {
return;
};
// The own list does not name a creator, so the viewer's own id and name
// fill that in. `me` is loaded before this view renders.
let is_admin = u.is_admin;
let own_id = u.id;
let own_name = u.name;
spawn_local(async move {
match api::list_shares().await {
Ok(s) => set_shares.set(Some(s)),
let got = if is_admin {
api::list_all_shares().await
} else {
api::list_shares().await.map(|own| {
own.into_iter()
.map(|share| AdminShare {
share,
creator_id: own_id,
creator_name: own_name.clone(),
creator_active: true,
})
.collect()
})
};
match got {
Ok(rs) => set_rows.set(Some(rs)),
Err(e) => set_load_err.set(Some(e.to_string())),
}
});
};
load();
// Delete every share of the current user (no server-side bulk endpoint).
let delete_one = Callback::new(move |id: i64| {
let is_admin = admin();
let toast2 = toast;
spawn_local(async move {
if end_share(is_admin, id).await {
set_rows.update(|rs| {
if let Some(list) = rs {
list.retain(|r| r.share.id != id);
}
});
show(toast2, i18n::t(i18n::k::SHARE_DELETED).to_string());
} else {
show_error(toast2, i18n::t(i18n::k::SHARE_DELETE_ERR).to_string());
}
});
});
// Bulk delete: no server-side bulk endpoint, so this walks the ids one by one.
let delete_all = move |_| {
set_confirm_all.set(false);
let Some(list) = shares.get() else {
return;
};
if list.is_empty() {
let Some(p) = confirm.get() else { return };
set_confirm.set(None);
if p.ids.is_empty() || busy.get() {
return;
}
let ids: Vec<i64> = list.iter().map(|s| s.id).collect();
set_busy_all.set(true);
let write = set_shares;
let toast2 = toast;
set_busy.set(true);
let total = p.ids.len();
let is_admin = admin();
spawn_local(async move {
let mut failed = 0usize;
for id in &ids {
if api::delete_share(*id).await.is_err() {
for id in &p.ids {
if !end_share(is_admin, *id).await {
failed += 1;
}
}
if failed == 0 {
write.set(Some(Vec::new()));
show(
toast2,
i18n::t(i18n::k::DELETED_N_SHARES)
.to_string()
.replace("{}", &ids.len().to_string()),
toast,
i18n::t_fmt(i18n::k::DELETED_N_SHARES, &total.to_string()),
);
} else if failed < ids.len() {
// Partial failure: re-read what is actually left.
if let Ok(s) = api::list_shares().await {
write.set(Some(s));
}
} else if failed < total {
show(
toast2,
i18n::t(i18n::k::DELETE_PARTIAL)
.replace("{}", &failed.to_string())
.replace("{}", &ids.len().to_string()),
toast,
i18n::t_fmt2(
i18n::k::DELETE_PARTIAL,
&failed.to_string(),
&total.to_string(),
),
);
} else {
show_error(toast2, i18n::t(i18n::k::SHARES_DELETE_ERR).to_string());
show_error(toast, i18n::t(i18n::k::SHARES_DELETE_ERR).to_string());
}
set_busy_all.set(false);
// Re-read: after a partial failure, editing the list by hand is
// not trustworthy.
load();
set_busy.set(false);
});
};
@@ -383,30 +576,52 @@ pub fn SharesView() -> impl IntoView {
<div class="shares-view">
<div class="view-header">
<div class="users-header">
<h3 class="admin-section-title">{i18n::tr(i18n::k::YOUR_SHARES)}</h3>
{move || {
let n = shares.get().map(|s| s.len()).unwrap_or(0);
if n > 0 {
view! {
<button
class="btn btn-sm btn-danger"
disabled=move || busy_all.get()
on:click=move |_| set_confirm_all.set(true)
>
<Icon name=IconName::Delete class="ic-btn".to_string()/>
{i18n::tr(i18n::k::DELETE_ALL)}
</button>
<h3 class="admin-section-title">
{move || {
if admin() {
i18n::t(i18n::k::ALL_SHARES).to_string()
} else {
i18n::t(i18n::k::YOUR_SHARES).to_string()
}
.into_view()
}}
</h3>
{move || {
let n = rows.get().map(|r| r.len()).unwrap_or(0);
(n > 0)
.then(|| {
view! {
<button
class="btn btn-sm btn-danger"
disabled=move || busy.get()
on:click=move |_: web_sys::MouseEvent| {
let Some(list) = rows.get() else { return };
let ids: Vec<i64> =
list.iter().map(|r| r.share.id).collect();
if ids.is_empty() {
return;
}
set_confirm.set(Some(Pending { ids, who: None }));
}
>
{icon_svg(IconName::Delete, "ic-btn")}
{i18n::tr(i18n::k::DELETE_ALL)}
</button>
}
})
.into_any()
}}
</div>
<p class="muted view-hint">
{move || {
if admin() {
i18n::t(i18n::k::ALL_SHARES_HINT).to_string()
} else {
view! {}.into_any()
i18n::t(i18n::k::SHARES_VIEW_HINT).to_string()
}
}}
</div>
<p class="muted view-hint">{i18n::tr(i18n::k::SHARES_VIEW_HINT)}</p>
</p>
</div>
{move || match shares.get() {
{move || match rows.get() {
None => match load_err.get() {
Some(msg) => view! {
<div class="card error-card">
@@ -418,15 +633,13 @@ pub fn SharesView() -> impl IntoView {
}
.into_view()
.into_any(),
None => view! {
<p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_view()
.into_any(),
None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
.into_view()
.into_any(),
},
Some(ref list) if list.is_empty() => view! {
<div class="empty-state">
<Icon name=IconName::Share class="empty-glyph".to_string()/>
{icon_svg(IconName::Share, "empty-glyph")}
<h3>{i18n::tr(i18n::k::NO_SHARES)}</h3>
<p class="muted">{i18n::tr(i18n::k::SHARES_EMPTY_HINT)}</p>
</div>
@@ -434,139 +647,156 @@ pub fn SharesView() -> impl IntoView {
.into_view()
.into_any(),
Some(ref list) => {
let items = list
.iter()
.map(|s| {
let s2 = s.clone();
let link = share_url(&s.token);
let dav_link = dav_share_url(&s.token);
let is_file = s.is_file;
let glyph = if s.is_file {
IconName::File
} else {
IconName::Folder
};
let mut meta = format!(
"{} · {}",
s.target,
if s.writable {
i18n::t(i18n::k::MODE_RW)
} else {
i18n::t(i18n::k::MODE_RO)
}
);
if let Some(e) = &s.expires_at {
meta.push_str(&format!(
" · {}",
i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e))
));
}
meta.push_str(&format!(
" · {}",
i18n::t_fmt(i18n::k::META_CREATED, &format_date(&s.created_at))
));
if s.has_password {
meta.push_str(&format!(" · {}", i18n::t(i18n::k::SHARE_PROTECTED)));
}
let is_admin = admin();
let mine = my_id();
let sections = group_rows(list, mine)
.into_iter()
.map(|g| {
let own = g.mine;
// One copy, shared by the section title and the
// delete-all question: both name the account.
let name: Arc<str> = g.name.into();
let items = g.shares
.iter()
.map(|s| share_row(s, toast, delete_one))
.collect::<Vec<_>>();
let ids: Vec<i64> = g.shares.iter().map(|s| s.id).collect();
let n = g.shares.len();
let gid = g.id;
let inactive = !g.active;
view! {
<div class="share-item">
<div class="share-item-main">
<div class="share-item-name">
<Icon name=glyph class="ic-row".to_string()/>
<span>{s.name.clone()}</span>
</div>
<div class="share-item-meta muted">{meta}</div>
</div>
<div class="share-item-actions">
<button
class="btn btn-sm"
title={i18n::tr(i18n::k::COPY_LINK)}
on:click=move |_| copy_to_clipboard(&link, toast)
>
<Icon name=IconName::Copy class="ic-btn".to_string()/>
{i18n::tr(i18n::k::COPY)}
</button>
// Folder shares only: a file share has
// no collection to mount.
<Show when=move || !is_file>
<button
class="btn btn-sm"
title={i18n::tr(i18n::k::COPY_DAV_LINK)}
on:click={
let dav = dav_link.clone();
move |_| copy_to_clipboard(&dav, toast)
}
>
<Icon name=IconName::Copy class="ic-btn".to_string()/>
"WebDAV"
</button>
</Show>
<button
class="btn btn-sm btn-danger"
title={i18n::tr(i18n::k::DELETE_SHARE)}
on:click=move |_| {
let id = s2.id;
let read = shares;
let write = set_shares;
let toast2 = toast;
spawn_local(async move {
if api::delete_share(id).await.is_ok() {
if let Some(mut cur) = read.get() {
cur.retain(|x| x.id != id);
write.set(Some(cur));
}
show(toast2, i18n::t(i18n::k::SHARE_DELETED).to_string());
// Own links open, everyone else's collapsed: an
// admin lands on their own links, not on a wall
// of other people's tokens.
<details
class="share-group"
prop:open=move || {
open.with(|m| m.get(&gid).copied().unwrap_or(own))
}
// `<details>` announces every toggle the user
// makes, already in its new state.
on:toggle=move |ev: web_sys::Event| {
let on = ev
.target()
.and_then(|t| {
t.dyn_into::<web_sys::HtmlDetailsElement>().ok()
})
.is_some_and(|d| d.open());
set_open.update(|m| {
m.insert(gid, on);
});
}
>
<summary class="share-group-head">
{icon_svg(IconName::ChevronRight, "share-caret")}
<span class="share-group-title">
{
let name = name.clone();
move || {
if own {
i18n::t(i18n::k::YOUR_SHARES).to_string()
} else {
show_error(toast2, i18n::t(i18n::k::SHARE_DELETE_ERR).to_string());
name.to_string()
}
});
}
}
>
<Icon name=IconName::Delete class="ic-btn".to_string()/>
{i18n::tr(i18n::k::DELETE)}
</button>
</div>
</div>
</span>
// Visible while collapsed: the case the
// admin view exists for.
{
(inactive)
.then(|| {
view! {
<span class="share-group-warn">
{i18n::tr(i18n::k::SHARE_OWNER_DISABLED)}
</span>
}
})
.into_any()
}
<span class="share-group-count muted">{n}</span>
{
// A non-admin has one section, and the heading
// button already deletes all of it.
let (ids, name) = (ids.clone(), name.clone());
is_admin
.then(|| {
view! {
<button
class="btn btn-sm btn-danger share-group-del"
title={i18n::tr(i18n::k::DELETE_ALL_TITLE)}
disabled=move || busy.get()
on:click=move |ev: web_sys::MouseEvent| {
// Otherwise this click toggles the
// section it sits in.
ev.prevent_default();
if ids.is_empty() {
return;
}
set_confirm.set(Some(Pending {
ids: ids.clone(),
who: (!own).then(|| name.to_string()),
}));
}
>
{
icon_svg(IconName::Delete, "ic-btn")
}
{i18n::t(i18n::k::DELETE_ALL)}
</button>
}
})
.into_any()
}
</summary>
<div class="share-list">{items}</div>
</details>
}
})
.collect::<Vec<_>>();
view! {
<div class="share-list">{items}</div>
}
.into_view()
.into_any()
view! { <div class="share-groups">{sections}</div> }
.into_view()
.into_any()
}
}}
// Delete-all confirmation.
{move || {
if confirm_all.get() {
let n = shares.get().map(|s| s.len()).unwrap_or(0);
// Delete-all confirmation, for one account or for everything.
{
move || {
let Some(p) = confirm.get() else {
return view! {}.into_any();
};
// Name the account: the links being deleted are not the
// viewer's.
let msg = match &p.who {
Some(name) => i18n::t_fmt(i18n::k::DELETE_ALL_USER_MSG, name),
None => i18n::t_fmt(i18n::k::DELETE_ALL_MSG, &p.ids.len().to_string()),
};
view! {
<Modal
class="card"
on_close=Callback::new(move |_| set_confirm_all.set(false))
on_close=Callback::new(move |_| set_confirm.set(None))
>
<h2 class="modal-title">{i18n::tr(i18n::k::DELETE_ALL_TITLE)}</h2>
<p class="modal-message">
{i18n::t_fmt(i18n::k::DELETE_ALL_MSG, &n.to_string())}
</p>
<p class="modal-message">{msg}</p>
<div class="modal-actions">
<button class="btn" on:click=move |_| set_confirm_all.set(false)>
<button class="btn" on:click=move |_| set_confirm.set(None)>
{i18n::tr(i18n::k::CANCEL)}
</button>
<button class="btn btn-danger" disabled=move || busy_all.get() on:click=delete_all>
{i18n::tr(i18n::k::DELETE_ALL)}
<button
class="btn btn-danger"
disabled=move || busy.get()
on:click=delete_all
>
{i18n::t(i18n::k::DELETE_ALL)}
</button>
</div>
</Modal>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
}
</div>
}
}
▾Mweb/src/views/shell.rs
@@ -352,7 +352,7 @@ pub fn ShellView(
}
.into_view()
.into_any(),
Section::Shares => view! { <SharesView/> }
Section::Shares => view! { <SharesView me=me/> }
.into_view()
.into_any(),
Section::Users if admin.get() => {