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>
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/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")
);
}
// ---------------------------------------------------------------------------
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"/>"#
}