Audit fixes: api-types crate (shared wire types + endpoint strings), native dates, dedup
- NEW api-types crate: all endpoint paths, query params, mutation ops and wire types (request bodies + responses) imported by both server and web, so route table, client URLs and serde shapes can't drift apart. Server handlers now return typed responses (Me, FilesResp, SaveResp, ShareInfo, AdminUser, Settings, OkResp, UploadResp) instead of json!(). - web: drop chrono (format_date + ms_to_rfc3339 now use browser-native js_sys::Date); drop request_no_body (== request(.., None)); drop dead UploadResp (upload() returns Result<(), ApiError>). - server: share display_name/validate_name/validate_password in api/common.rs (was 3x display_name + 2x validation); remove dead non-Arc HasState impl, thiserror derive on ApiError (Display unused), tokio 'signal' feature (no handler), stale #[allow(dead_code)] on AuthUser.share; fs validate_name/copy_recursive made private. - just test now covers api-types too (103 tests, all green). Co-Authored-By: Qwen3.8 27b
MCargo.lock
@@ -104,6 +104,14 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "api-types"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "argon2"
version = "0.5.3"
@@ -2208,6 +2216,7 @@ name = "server"
version = "0.1.0"
dependencies = [
"anyhow",
"api-types",
"argon2",
"axum",
"bytes",
@@ -2342,16 +2351,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -2618,7 +2617,6 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
@@ -3016,7 +3014,7 @@ dependencies = [
name = "web"
version = "0.1.0"
dependencies = [
"chrono",
"api-types",
"console_error_panic_hook",
"gloo-timers",
"js-sys",
MCargo.toml
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["server", "web"]
members = ["api-types", "server", "web"]
[profile.release]
strip = true
Aapi-types/Cargo.toml
@@ -0,0 +1,10 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
serde_json = "1"
Aapi-types/src/lib.rs
@@ -0,0 +1,278 @@
//! The HTTP wire contract of filebrowser-ng in one place.
//!
//! Both the server (axum) and the web frontend (wasm `fetch`) import these
//! endpoint paths, query params and serde types, so the two sides cannot
//! drift apart. Serde only — no axum, no wasm dependencies.
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Endpoint paths (single source of truth for the route table and the client)
// ---------------------------------------------------------------------------
pub const AUTH_LOGIN: &str = "/api/auth/login";
pub const AUTH_LOGOUT: &str = "/api/auth/logout";
pub const AUTH_ME: &str = "/api/auth/me";
pub const AUTH_SETUP: &str = "/api/auth/setup";
/// File operations: `{FILES}/{root_id}` and `{FILES}/{root_id}/{path...}`.
pub const FILES: &str = "/api/files";
/// Share management (authenticated): `{SHARES}` and `{SHARES}/{id}`.
pub const SHARES: &str = "/api/shares";
/// Public share resolve (no login): `{SHARE}/{token}`.
pub const SHARE: &str = "/api/share";
/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
pub const ADMIN_USERS: &str = "/api/admin/users";
pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
// ---------------------------------------------------------------------------
// Query params
// ---------------------------------------------------------------------------
/// `?action=...` on file URLs; without it the route lists the directory.
pub const P_ACTION: &str = "action";
pub const ACTION_DOWNLOAD: &str = "download";
pub const ACTION_PREVIEW: &str = "preview";
pub const ACTION_CONTENT: &str = "content";
/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
pub const P_FORMAT: &str = "format";
/// `?share=<token>` — authenticate file calls with a public share token.
pub const P_SHARE: &str = "share";
/// `?overwrite=true|1` on mutations and uploads.
pub const P_OVERWRITE: &str = "overwrite";
// ---------------------------------------------------------------------------
// Mutation ops (`Mutation::op`)
// ---------------------------------------------------------------------------
pub const OP_RENAME: &str = "rename";
pub const OP_MOVE: &str = "move";
pub const OP_COPY: &str = "copy";
// ---------------------------------------------------------------------------
// Request bodies (client → server)
// ---------------------------------------------------------------------------
#[derive(Serialize, Deserialize)]
pub struct Credentials {
pub name: String,
pub password: String,
}
/// Rename / move / copy (one body for all file mutations).
#[derive(Serialize, Deserialize)]
pub struct Mutation {
/// One of [`OP_RENAME`], [`OP_MOVE`], [`OP_COPY`].
pub op: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dst_root_id: Option<i64>,
/// Destination directory, relative to `dst_root_id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dst: Option<String>,
#[serde(default)]
pub overwrite: bool,
}
/// A user folder: path relative to the server root + access mode.
#[derive(Serialize, Deserialize)]
pub struct Root {
/// Path relative to the server root; "." means the whole root.
pub path: String,
/// "rw" or "ro".
#[serde(default = "default_rw")]
pub mode: String,
}
fn default_rw() -> String {
"rw".to_string()
}
#[derive(Serialize, Deserialize)]
pub struct CreateUser {
pub name: String,
pub password: String,
#[serde(default)]
pub is_admin: bool,
#[serde(default)]
pub roots: Vec<Root>,
}
#[derive(Serialize, Deserialize)]
pub struct UpdateUser {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_admin: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roots: Option<Vec<Root>>,
}
/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct Settings {
pub allow_writable_shares: bool,
}
#[derive(Serialize, Deserialize)]
pub struct CreateShare {
pub root_id: i64,
/// Item path relative to the root ("" or "." for the root itself).
pub path: String,
#[serde(default)]
pub writable: bool,
/// Absolute expiry as RFC 3339; absent = never.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
// ---------------------------------------------------------------------------
// Responses (server → client)
// ---------------------------------------------------------------------------
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Entry {
pub name: String,
pub is_dir: bool,
pub size: u64,
/// RFC 3339 UTC modification time.
pub mtime: String,
}
#[derive(Serialize, Deserialize)]
pub struct FilesResp {
pub entries: Vec<Entry>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct UserInfo {
pub id: i64,
pub name: String,
pub is_admin: bool,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RootInfo {
pub id: i64,
pub name: String,
pub path: String,
pub mode: String,
}
/// GET `{AUTH_ME}`.
#[derive(Serialize, Deserialize, Clone)]
pub struct Me {
/// True while no users exist yet (first-boot setup).
pub first_boot: bool,
/// None on first boot.
pub user: Option<UserInfo>,
pub roots: Vec<RootInfo>,
pub allow_writable_shares: bool,
}
/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
#[derive(Serialize, Deserialize, Clone)]
pub struct ShareInfo {
pub id: i64,
pub token: String,
/// Display name (file/folder name, or the root's name for ".").
pub name: String,
pub is_file: bool,
pub writable: bool,
/// Path relative to the server root.
pub target: String,
/// RFC 3339 UTC creation time.
pub created_at: String,
/// RFC 3339 UTC expiry; None = never.
pub expires_at: Option<String>,
/// Synthetic root id to use in file API calls.
pub root_id: i64,
}
/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
#[derive(Serialize, Deserialize, Clone)]
pub struct AdminUser {
pub id: i64,
pub name: String,
pub is_admin: bool,
pub active: bool,
pub roots: Vec<RootInfo>,
}
/// Acknowledges a successful mutation: `{"ok": true}`.
#[derive(Serialize, Deserialize)]
pub struct OkResp {
pub ok: bool,
}
/// Upload success: `{"ok": true, "uploaded": n}`.
#[derive(Serialize, Deserialize)]
pub struct UploadResp {
pub ok: bool,
pub uploaded: usize,
}
/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
#[derive(Serialize, Deserialize)]
pub struct SaveResp {
pub ok: bool,
pub mtime: i64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mutation_round_trip_skips_absent_fields() {
let m = Mutation {
op: OP_MOVE.to_string(),
new_name: None,
dst_root_id: Some(3),
dst: Some("docs".into()),
overwrite: true,
};
let s = serde_json::to_string(&m).unwrap();
assert!(!s.contains("new_name"));
let back: Mutation = serde_json::from_str(&s).unwrap();
assert_eq!(back.dst_root_id, Some(3));
assert_eq!(back.op, OP_MOVE);
}
#[test]
fn mutation_defaults_missing_fields() {
let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
assert!(!m.overwrite);
assert_eq!(m.dst, None);
}
#[test]
fn root_defaults_mode_to_rw() {
let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
assert_eq!(r.mode, "rw");
}
#[test]
fn me_round_trip() {
let me = Me {
first_boot: false,
user: Some(UserInfo {
id: 1,
name: "admin".into(),
is_admin: true,
}),
roots: vec![RootInfo {
id: 1,
name: "root".into(),
path: ".".into(),
mode: "rw".into(),
}],
allow_writable_shares: false,
};
let s = serde_json::to_string(&me).unwrap();
let back: Me = serde_json::from_str(&s).unwrap();
assert_eq!(back.roots.len(), 1);
}
}
Mjustfile
@@ -50,7 +50,7 @@ run: build
# Server test suite (unit + API integration tests).
test:
cargo test -p server
cargo test -p server -p api-types
# Lint: formatting check + clippy with warnings denied.
lint:
Mserver/Cargo.toml
@@ -8,6 +8,7 @@ name = "filebrowser-ng"
path = "src/main.rs"
[dependencies]
api-types = { path = "../api-types" }
anyhow = "1"
argon2 = "0.5"
axum = "0.8"
@@ -20,7 +21,7 @@ rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "fs", "io-util", "sync"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "sync"] }
tar = "0.4"
flate2 = "1"
zstd = "0.13"
Mserver/src/api/admin.rs
@@ -3,103 +3,47 @@
use std::sync::Arc;
use api_types::{AdminUser, CreateUser, OkResp, Root, RootInfo, Settings, UpdateUser};
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use serde::Deserialize;
use crate::api::common::AdminUser;
use crate::api::common::AdminUser as AdminGuard;
use crate::api::common::{display_name, validate_name, validate_password};
use crate::auth;
use crate::db::Db;
use crate::error::{ApiError, AppState};
use crate::fs;
// ---------------------------------------------------------------------------
// Bodies
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct RootBody {
/// Path relative to the server root; "." means the whole root.
path: String,
/// "rw" or "ro".
#[serde(default = "default_rw")]
mode: String,
}
fn default_rw() -> String {
"rw".to_string()
}
#[derive(Deserialize)]
pub struct CreateUserBody {
name: String,
password: String,
#[serde(default)]
is_admin: bool,
#[serde(default)]
roots: Vec<RootBody>,
}
#[derive(Deserialize)]
pub struct UpdateUserBody {
#[serde(default)]
password: Option<String>,
#[serde(default)]
is_admin: Option<bool>,
#[serde(default)]
active: Option<bool>,
#[serde(default)]
roots: Option<Vec<RootBody>>,
}
#[derive(Deserialize)]
pub struct SettingsBody {
allow_writable_shares: bool,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Display name for a root path (folder name, or the server root's name for ".").
fn display_name(state_root: &std::path::Path, rel: &str) -> String {
let name = if rel == "." {
state_root.file_name()
} else {
std::path::Path::new(rel)
.file_name()
.filter(|_| !std::path::Path::new(rel).as_os_str().is_empty())
};
name.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| rel.to_string())
}
fn root_json(state: &AppState, r: &crate::db::RootRow) -> serde_json::Value {
serde_json::json!({
"id": r.id,
"name": display_name(&state.root, &r.path),
"path": r.path,
"mode": r.mode,
})
fn root_info(state: &AppState, r: &crate::db::RootRow) -> RootInfo {
RootInfo {
id: r.id,
name: display_name(&state.root, &r.path),
path: r.path.clone(),
mode: r.mode.clone(),
}
}
async fn user_json(db: &Db, state: &AppState, user: &crate::db::User) -> serde_json::Value {
async fn user_info(db: &Db, state: &AppState, user: &crate::db::User) -> AdminUser {
let roots = db.user_roots(user.id).await;
serde_json::json!({
"id": user.id,
"name": user.name,
"is_admin": user.is_admin,
"active": user.active,
"roots": roots.iter().map(|r| root_json(state, r)).collect::<Vec<_>>(),
})
AdminUser {
id: user.id,
name: user.name.clone(),
is_admin: user.is_admin,
active: user.active,
roots: roots.iter().map(|r| root_info(state, r)).collect(),
}
}
/// Validate each requested root path (must exist, be a directory, and stay
/// inside the server root) and its mode. Returns the (path, mode) pairs.
async fn validate_roots(
state: &AppState,
roots: &[RootBody],
roots: &[Root],
) -> Result<Vec<(String, String)>, ApiError> {
let mut out = Vec::new();
for r in roots {
@@ -127,27 +71,6 @@ async fn validate_roots(
Ok(out)
}
fn validate_name(name: &str) -> Result<(), ApiError> {
let n = name.trim();
if n.is_empty() || n.len() > 64 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"name must be 1–64 characters",
));
}
Ok(())
}
fn validate_password(pw: &str) -> Result<(), ApiError> {
if pw.len() < 8 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"password must be at least 8 characters",
));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
@@ -155,22 +78,22 @@ fn validate_password(pw: &str) -> Result<(), ApiError> {
/// GET /api/admin/users — list all users with their roots.
pub async fn list_users(
State(state): State<Arc<AppState>>,
_admin: AdminUser,
) -> Result<Json<serde_json::Value>, ApiError> {
_admin: AdminGuard,
) -> Result<Json<Vec<AdminUser>>, ApiError> {
let users = state.db.all_users().await;
let mut values = Vec::with_capacity(users.len());
let mut out = Vec::with_capacity(users.len());
for u in &users {
values.push(user_json(&state.db, &state, u).await);
out.push(user_info(&state.db, &state, u).await);
}
Ok(Json(serde_json::json!(values)))
Ok(Json(out))
}
/// POST /api/admin/users — create a user.
pub async fn create_user(
State(state): State<Arc<AppState>>,
_admin: AdminUser,
Json(body): Json<CreateUserBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
_admin: AdminGuard,
Json(body): Json<CreateUser>,
) -> Result<Json<AdminUser>, ApiError> {
let name = body.name.trim().to_string();
validate_name(&name)?;
validate_password(&body.password)?;
@@ -192,17 +115,17 @@ pub async fn create_user(
.db
.create_user(&name, &pass_hash, body.is_admin, &roots)
.await?;
Ok(Json(user_json(&state.db, &state, &user).await))
Ok(Json(user_info(&state.db, &state, &user).await))
}
/// PUT /api/admin/users/{id} — update a user (password / is_admin / active /
/// roots; all optional).
pub async fn update_user(
State(state): State<Arc<AppState>>,
admin: AdminUser,
admin: AdminGuard,
AxumPath(id): AxumPath<i64>,
Json(body): Json<UpdateUserBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
Json(body): Json<UpdateUser>,
) -> Result<Json<AdminUser>, ApiError> {
let target = state
.db
.find_user_by_id(id)
@@ -261,15 +184,15 @@ pub async fn update_user(
.find_user_by_id(id)
.await
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
Ok(Json(user_json(&state.db, &state, &updated).await))
Ok(Json(user_info(&state.db, &state, &updated).await))
}
/// DELETE /api/admin/users/{id} — delete a user (not yourself).
pub async fn delete_user(
State(state): State<Arc<AppState>>,
admin: AdminUser,
admin: AdminGuard,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<OkResp>, ApiError> {
if id == admin.user.id {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
@@ -290,30 +213,28 @@ pub async fn delete_user(
if !state.db.delete_user(id).await {
return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found"));
}
Ok(Json(serde_json::json!({ "ok": true })))
Ok(Json(OkResp { ok: true }))
}
/// GET /api/admin/settings
pub async fn get_settings(
State(state): State<Arc<AppState>>,
_admin: AdminUser,
) -> Result<Json<serde_json::Value>, ApiError> {
Ok(Json(serde_json::json!({
"allow_writable_shares": state.db.allow_writable_shares().await,
})))
_admin: AdminGuard,
) -> Result<Json<Settings>, ApiError> {
Ok(Json(Settings {
allow_writable_shares: state.db.allow_writable_shares().await,
}))
}
/// PUT /api/admin/settings
pub async fn update_settings(
State(state): State<Arc<AppState>>,
_admin: AdminUser,
Json(body): Json<SettingsBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
_admin: AdminGuard,
Json(body): Json<Settings>,
) -> Result<Json<Settings>, ApiError> {
state
.db
.set_allow_writable_shares(body.allow_writable_shares)
.await?;
Ok(Json(serde_json::json!({
"allow_writable_shares": body.allow_writable_shares,
})))
Ok(Json(body))
}
Mserver/src/api/auth.rs
@@ -1,21 +1,15 @@
use std::path::Path;
use std::sync::Arc;
use api_types::{Credentials, Me, OkResp, RootInfo, UserInfo};
use axum::Json;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use crate::api::common::{display_name, validate_name, validate_password};
use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie};
use crate::error::{ApiError, AppState};
#[derive(Deserialize)]
pub struct CredentialsBody {
pub name: String,
pub password: String,
}
/// GET /api/auth/me
///
/// - No users at all → `200 {"first_boot": true}`
@@ -24,14 +18,14 @@ pub struct CredentialsBody {
pub async fn me(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<Me>, ApiError> {
if state.db.user_count().await == 0 {
return Ok(Json(serde_json::json!({
"first_boot": true,
"user": null,
"roots": [],
"allow_writable_shares": false
})));
return Ok(Json(Me {
first_boot: true,
user: None,
roots: Vec::new(),
allow_writable_shares: false,
}));
}
let Some(token) = parse_session_cookie(&headers) else {
@@ -44,65 +38,40 @@ pub async fn me(
));
};
let roots = state
let roots: Vec<RootInfo> = state
.db
.user_roots(user.id)
.await
.into_iter()
.map(|r| {
serde_json::json!({
"id": r.id,
"name": display_name(&state.root, &r.path),
"path": r.path,
"mode": r.mode,
})
.map(|r| RootInfo {
id: r.id,
name: display_name(&state.root, &r.path),
path: r.path,
mode: r.mode,
})
.collect::<Vec<_>>();
Ok(Json(serde_json::json!({
"first_boot": false,
"user": {
"id": user.id,
"name": user.name,
"is_admin": user.is_admin,
},
"roots": roots,
"allow_writable_shares": state.db.allow_writable_shares().await,
})))
}
/// Display name for a user root: the folder name, or the root folder's
/// own name when the user root is the whole root (".").
fn display_name(server_root: &Path, rel: &str) -> String {
let p = Path::new(rel);
let name = if rel == "." {
server_root.file_name()
} else {
p.file_name().filter(|_| !p.as_os_str().is_empty())
};
name.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| rel.to_string())
.collect();
Ok(Json(Me {
first_boot: false,
user: Some(UserInfo {
id: user.id,
name: user.name,
is_admin: user.is_admin,
}),
roots,
allow_writable_shares: state.db.allow_writable_shares().await,
}))
}
/// POST /api/auth/setup — create the first admin account.
/// Only available while no users exist.
pub async fn setup(
State(state): State<Arc<AppState>>,
Json(body): Json<CredentialsBody>,
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let name = body.name.trim();
if name.is_empty() || name.len() > 64 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"name must be 1–64 characters",
));
}
if body.password.len() < 8 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"password must be at least 8 characters",
));
}
validate_name(name)?;
validate_password(&body.password)?;
if state.db.user_count().await > 0 {
return Err(ApiError::new(
StatusCode::CONFLICT,
@@ -121,7 +90,7 @@ pub async fn setup(
let token = auth::random_token();
state.db.create_session(user.id, &token).await?;
let mut res = Json(serde_json::json!({ "ok": true })).into_response();
let mut res = Json(OkResp { ok: true }).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, state.https).parse().unwrap(),
@@ -132,7 +101,7 @@ pub async fn setup(
/// POST /api/auth/login
pub async fn login(
State(state): State<Arc<AppState>>,
Json(body): Json<CredentialsBody>,
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let Some(user) = state.db.verify_password(&body.name, &body.password).await else {
return Err(ApiError::new(
@@ -144,7 +113,7 @@ pub async fn login(
let token = auth::random_token();
state.db.create_session(user.id, &token).await?;
let mut res = Json(serde_json::json!({ "ok": true })).into_response();
let mut res = Json(OkResp { ok: true }).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, state.https).parse().unwrap(),
@@ -157,7 +126,7 @@ pub async fn logout(State(state): State<Arc<AppState>>, headers: HeaderMap) -> R
if let Some(token) = parse_session_cookie(&headers) {
let _ = state.db.delete_session(&token).await;
}
let mut res = Json(serde_json::json!({ "ok": true })).into_response();
let mut res = Json(OkResp { ok: true }).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
clear_session_cookie(state.https).parse().unwrap(),
Mserver/src/api/common.rs
@@ -2,6 +2,7 @@
use std::sync::Arc;
use api_types::P_SHARE;
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;
@@ -16,7 +17,6 @@ pub struct AuthUser {
/// Present when authenticated via a public share token. The single entry
/// in `roots` is the shared item (its path is the share's `target`), so all
/// file operations are scoped to it.
#[allow(dead_code)]
pub share: Option<ShareRow>,
}
@@ -86,7 +86,7 @@ fn share_token_from_request(parts: &Parts) -> Option<String> {
if let Some(q) = parts.uri.query() {
for pair in q.split('&') {
if let Some((k, v)) = pair.split_once('=')
&& k == "share"
&& k == P_SHARE
&& !v.is_empty()
{
return Some(v.to_string());
@@ -112,10 +112,43 @@ impl HasState for Arc<AppState> {
}
}
impl HasState for AppState {
fn state(&self) -> &AppState {
self
// ---------------------------------------------------------------------------
// Shared validation / naming helpers
// ---------------------------------------------------------------------------
/// Display name for a root path: the file/folder name, or the server root's
/// own name when the path is the whole root (".").
pub(crate) fn display_name(server_root: &std::path::Path, rel: &str) -> String {
let name = if rel == "." {
server_root.file_name()
} else {
std::path::Path::new(rel)
.file_name()
.filter(|_| !std::path::Path::new(rel).as_os_str().is_empty())
};
name.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| rel.to_string())
}
pub(crate) fn validate_name(name: &str) -> Result<(), ApiError> {
let n = name.trim();
if n.is_empty() || n.len() > 64 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"name must be 1–64 characters",
));
}
Ok(())
}
pub(crate) fn validate_password(pw: &str) -> Result<(), ApiError> {
if pw.len() < 8 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"password must be at least 8 characters",
));
}
Ok(())
}
/// Extractor for admin-only routes: a signed-in user who is an admin.
Mserver/src/api/files.rs
@@ -30,27 +30,17 @@ use crate::archive::{self, ArchiveFormat};
use crate::db::{RootRow, ShareRow};
use crate::error::{ApiError, AppState};
use crate::fs::{self, FsError};
use api_types::{
FilesResp, Mutation, OP_COPY, OP_MOVE, OP_RENAME, OkResp, P_OVERWRITE, SaveResp, UploadResp,
};
/// Upper bound for the in-memory text endpoint (preview, later editor).
const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
// ---------------------------------------------------------------------------
// Bodies / query params
// Query params
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct MutationBody {
pub op: String, // "rename" | "move" | "copy"
#[serde(default)]
pub new_name: Option<String>,
#[serde(default)]
pub dst_root_id: Option<i64>,
#[serde(default)]
pub dst: Option<String>,
#[serde(default)]
pub overwrite: bool,
}
/// Query params for `GET /api/files/{root_id}/{*path}`. Without `action` the
/// route lists the directory; `?action=download|preview|content` serve the
/// item itself.
@@ -76,9 +66,11 @@ pub async fn file_get(
) -> Result<Response, ApiError> {
let (root_id, req_rel) = path.0;
match query.action.as_deref() {
Some("download") => download(state, auth, root_id, req_rel, query.format.as_deref()).await,
Some("preview") => preview(state, auth, root_id, req_rel).await,
Some("content") => content(state, auth, root_id, req_rel).await,
Some(a) if a == api_types::ACTION_DOWNLOAD => {
download(state, auth, root_id, req_rel, query.format.as_deref()).await
}
Some(a) if a == api_types::ACTION_PREVIEW => preview(state, auth, root_id, req_rel).await,
Some(a) if a == api_types::ACTION_CONTENT => content(state, auth, root_id, req_rel).await,
_ => {
let json = list_inner(state, auth, root_id, req_rel).await?;
Ok(json.into_response())
@@ -97,11 +89,15 @@ pub async fn list_root(
) -> Result<Response, ApiError> {
let root_id = path.0;
match query.action.as_deref() {
Some("download") => {
Some(a) if a == api_types::ACTION_DOWNLOAD => {
download(state, auth, root_id, String::new(), query.format.as_deref()).await
}
Some("preview") => preview(state, auth, root_id, String::new()).await,
Some("content") => content(state, auth, root_id, String::new()).await,
Some(a) if a == api_types::ACTION_PREVIEW => {
preview(state, auth, root_id, String::new()).await
}
Some(a) if a == api_types::ACTION_CONTENT => {
content(state, auth, root_id, String::new()).await
}
_ => {
let json = list_inner(state, auth, root_id, String::new()).await?;
Ok(json.into_response())
@@ -114,7 +110,7 @@ async fn list_inner(
auth: AuthUser,
root_id: i64,
req_rel: String,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<FilesResp>, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let server_root = state.root.clone();
let root_rel = root.path.clone();
@@ -127,7 +123,7 @@ async fn list_inner(
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "entries": entries })))
Ok(Json(FilesResp { entries }))
}
// ---------------------------------------------------------------------------
@@ -287,9 +283,9 @@ pub async fn file_put(
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<SaveResp>, ApiError> {
let (root_id, req_rel) = path.0;
if query.action.as_deref() != Some("content") {
if query.action.as_deref() != Some(api_types::ACTION_CONTENT) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"expected action=content",
@@ -313,7 +309,7 @@ pub async fn file_put(
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true, "mtime": mtime })))
Ok(Json(SaveResp { ok: true, mtime }))
}
/// Stream a single file to the client with the right disposition.
@@ -446,7 +442,7 @@ pub async fn dispatch_root(
path: AxumPath<i64>,
headers: axum::http::HeaderMap,
req: axum::http::Request<axum::body::Body>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Response, ApiError> {
dispatch_inner(state, auth, path.0, String::new(), headers, req).await
}
@@ -457,7 +453,7 @@ pub async fn dispatch(
path: AxumPath<(i64, String)>,
headers: axum::http::HeaderMap,
req: axum::http::Request<axum::body::Body>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Response, ApiError> {
let (root_id, req_rel) = path.0;
dispatch_inner(state, auth, root_id, req_rel, headers, req).await
}
@@ -469,7 +465,7 @@ async fn dispatch_inner(
req_rel: String,
headers: axum::http::HeaderMap,
req: axum::http::Request<axum::body::Body>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Response, ApiError> {
let ct = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
@@ -482,12 +478,14 @@ async fn dispatch_inner(
let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
.await
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?;
let body: MutationBody = axum::Json::from_bytes(&bytes)
let body: Mutation = axum::Json::from_bytes(&bytes)
.map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid request body"))?
.0;
return mutation(state, auth, root_id, req_rel, body).await;
return Ok(mutation(state, auth, root_id, req_rel, body)
.await?
.into_response());
}
mkdir(state, auth, root_id, req_rel).await
Ok(mkdir(state, auth, root_id, req_rel).await?.into_response())
}
// ---------------------------------------------------------------------------
@@ -499,7 +497,7 @@ async fn mkdir(
auth: AuthUser,
root_id: i64,
req_rel: String,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<OkResp>, ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
if req_rel.trim().is_empty() {
return Err(ApiError::new(
@@ -511,7 +509,7 @@ async fn mkdir(
tokio::task::spawn_blocking(move || fs::mkdir(&server_root, &root_rel, &rel))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true })))
Ok(Json(OkResp { ok: true }))
}
// ---------------------------------------------------------------------------
@@ -523,10 +521,10 @@ async fn mutation(
auth: AuthUser,
root_id: i64,
req_rel: String,
body: MutationBody,
) -> Result<Json<serde_json::Value>, ApiError> {
body: Mutation,
) -> Result<Json<OkResp>, ApiError> {
match body.op.as_str() {
"rename" => {
OP_RENAME => {
let new_name = body
.new_name
.as_deref()
@@ -544,9 +542,9 @@ async fn mutation(
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true })))
Ok(Json(OkResp { ok: true }))
}
"move" | "copy" => {
OP_MOVE | OP_COPY => {
let dst_root_id = body
.dst_root_id
.ok_or_else(|| ApiError::new(StatusCode::BAD_REQUEST, "dst_root_id is required"))?;
@@ -567,7 +565,7 @@ async fn mutation(
req_rel,
body.overwrite,
);
let op_is_move = body.op == "move";
let op_is_move = body.op == OP_MOVE;
tokio::task::spawn_blocking(move || {
if op_is_move {
fs::move_item(&server_root, &src_rel, &rel, &dst_rel, &dst_path, overwrite)
@@ -577,7 +575,7 @@ async fn mutation(
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "ok": true })))
Ok(Json(OkResp { ok: true }))
}
_ => Err(ApiError::new(
StatusCode::BAD_REQUEST,
@@ -621,7 +619,7 @@ async fn upload(
root_id: i64,
req_rel: String,
req: axum::http::Request<axum::body::Body>,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Response, ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
let base = {
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
@@ -752,9 +750,7 @@ async fn upload(
.with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })),
);
}
Ok(Json(
serde_json::json!({ "ok": true, "uploaded": uploaded }),
))
Ok(Json(UploadResp { ok: true, uploaded }).into_response())
}
fn parse_boundary(content_type: &str) -> Option<String> {
@@ -769,12 +765,12 @@ fn parse_boundary(content_type: &str) -> Option<String> {
fn parse_overwrite(uri: &axum::http::Uri) -> bool {
uri.query()
.map(|q| {
q.split('&')
.any(|kv| kv == "overwrite=true" || kv == "overwrite=1")
let t = format!("{P_OVERWRITE}=true");
let o = format!("{P_OVERWRITE}=1");
q.split('&').any(|kv| kv == t || kv == o)
})
.unwrap_or(false)
}
fn validate_rel_path(name: &str) -> Result<(), ApiError> {
for c in std::path::Path::new(name).components() {
match c {
Mserver/src/api/mod.rs
@@ -1,5 +1,8 @@
use std::sync::Arc;
use api_types::{
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, FILES, SHARE, SHARES,
};
use axum::Router;
use axum::http::HeaderValue;
use axum::routing::{delete, get, post, put};
@@ -24,27 +27,35 @@ mod shares;
mod spa;
pub fn router(state: Arc<AppState>) -> Router {
// Route patterns: the server side of the shared endpoint strings in
// `api_types` (axum copies them into the route table on insert).
let files_root = format!("{FILES}/{{root_id}}");
let files_item = format!("{FILES}/{{root_id}}/{{*path}}");
let shares_id = format!("{SHARES}/{{id}}");
let share_token = format!("{SHARE}/{{token}}");
let admin_user_id = format!("{ADMIN_USERS}/{{id}}");
Router::new()
.route("/api/auth/login", post(auth::login))
.route("/api/auth/logout", post(auth::logout))
.route("/api/auth/me", get(auth::me))
.route("/api/auth/setup", post(auth::setup))
.route("/api/files/{root_id}", get(files::list_root))
.route("/api/files/{root_id}/{*path}", get(files::file_get))
.route("/api/files/{root_id}/{*path}", put(files::file_put))
.route("/api/files/{root_id}", post(files::dispatch_root))
.route("/api/files/{root_id}/{*path}", post(files::dispatch))
.route("/api/files/{root_id}/{*path}", delete(files::delete))
.route("/api/shares", get(shares::list))
.route("/api/shares", post(shares::create))
.route("/api/shares/{id}", delete(shares::delete))
.route("/api/share/{token}", get(shares::resolve))
.route("/api/admin/users", get(admin::list_users))
.route("/api/admin/users", post(admin::create_user))
.route("/api/admin/users/{id}", put(admin::update_user))
.route("/api/admin/users/{id}", delete(admin::delete_user))
.route("/api/admin/settings", get(admin::get_settings))
.route("/api/admin/settings", put(admin::update_settings))
.route(AUTH_LOGIN, post(auth::login))
.route(AUTH_LOGOUT, post(auth::logout))
.route(AUTH_ME, get(auth::me))
.route(AUTH_SETUP, post(auth::setup))
.route(&files_root, get(files::list_root))
.route(&files_item, get(files::file_get))
.route(&files_item, put(files::file_put))
.route(&files_root, post(files::dispatch_root))
.route(&files_item, post(files::dispatch))
.route(&files_item, delete(files::delete))
.route(SHARES, get(shares::list))
.route(SHARES, post(shares::create))
.route(&shares_id, delete(shares::delete))
.route(&share_token, get(shares::resolve))
.route(ADMIN_USERS, get(admin::list_users))
.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_SETTINGS, get(admin::get_settings))
.route(ADMIN_SETTINGS, put(admin::update_settings))
.fallback(spa::fallback)
.with_state(state)
// Hard security headers on every response (API and static alike).
Mserver/src/error.rs
@@ -15,9 +15,8 @@ pub struct AppState {
}
/// API error. `extra` is optionally merged into the JSON body (e.g. a list of
/// conflicting file names on a 409).
#[derive(Debug, thiserror::Error)]
#[error("{1}")]
/// conflicting file names on a 409). Rendered by `IntoResponse` below; the
/// error string itself is never displayed, so no `Display` impl is needed.
pub struct ApiError(pub StatusCode, pub String, pub Option<serde_json::Value>);
impl ApiError {
Mserver/src/fs.rs
@@ -7,6 +7,8 @@ use std::time::UNIX_EPOCH;
use chrono::DateTime;
use api_types::Entry;
use crate::error::ApiError;
#[derive(Debug, thiserror::Error)]
@@ -91,14 +93,6 @@ fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Entry {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub mtime: String,
}
/// List a directory (blocking — call via spawn_blocking).
pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> {
let rd = std::fs::read_dir(dir).map_err(|e| match e.kind() {
@@ -159,7 +153,7 @@ pub fn resolve_dir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<
}
/// Validate a new single-component name (for rename / new folder).
pub fn validate_name(name: &str) -> Result<(), FsError> {
fn validate_name(name: &str) -> Result<(), FsError> {
let p = Path::new(name);
if name.is_empty()
|| p.components().count() != 1
@@ -394,7 +388,7 @@ fn check_move_conflict(to: &Path, from: &Path, overwrite: bool) -> Result<(), Fs
}
/// Recursively copy a file or directory tree, preserving mtime.
pub fn copy_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
fn copy_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
let meta = std::fs::metadata(src).map_err(|e| io_err(e, src))?;
if meta.is_dir() {
std::fs::create_dir(dst).map_err(|e| io_err(e, dst))?;
Mweb/Cargo.toml
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4"
api-types = { path = "../api-types" }
console_error_panic_hook = "0.1"
gloo-timers = { version = "0.3", features = ["futures"] }
js-sys = "0.3"
Mweb/src/api.rs
@@ -1,9 +1,25 @@
//! Typed HTTP client for the filebrowser-ng API.
//!
//! Endpoint paths, query params and wire types all come from the shared
//! `api_types` crate (the same one the server's route table and handlers
//! use), so the two sides cannot drift apart.
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
use api_types::{
ACTION_CONTENT, ACTION_DOWNLOAD, ACTION_PREVIEW, ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN,
AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare, CreateUser, Credentials, FILES, Mutation,
OP_COPY, OP_MOVE, OP_RENAME, P_ACTION, P_FORMAT, P_OVERWRITE, P_SHARE, Root, SHARE, SHARES,
Settings, UpdateUser,
};
pub use api_types::{
AdminUser, Entry, FilesResp, Me, OkResp, RootInfo, SaveResp, ShareInfo, UserInfo,
};
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
/// Non-2xx response. `skipped` carries the server's conflict file list
@@ -41,72 +57,8 @@ impl ApiError {
}
}
#[derive(Deserialize, Clone)]
pub struct Me {
pub first_boot: bool,
#[serde(default)]
pub user: Option<UserInfo>,
#[serde(default)]
pub roots: Vec<RootInfo>,
/// Whether the server allows users to create writable (read-write) shares.
#[serde(default)]
pub allow_writable_shares: bool,
}
#[derive(Deserialize, Clone)]
pub struct UserInfo {
#[allow(dead_code)] // used from milestone 7 onwards
pub id: i64,
pub name: String,
pub is_admin: bool,
}
#[derive(Deserialize, Clone)]
pub struct RootInfo {
pub id: i64,
pub name: String,
pub path: String,
pub mode: String,
}
#[derive(Deserialize, Clone, Debug, PartialEq)]
pub struct Entry {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub mtime: String,
}
#[derive(Deserialize)]
pub struct FilesResp {
pub entries: Vec<Entry>,
}
#[derive(Deserialize)]
pub struct UploadResp {
#[allow(dead_code)]
pub uploaded: usize,
}
/// Acknowledges a successful 2xx response whose body we don't care about.
/// Accepts any JSON value (the server sends `{"ok": true}`).
pub struct OkResp;
impl<'de> Deserialize<'de> for OkResp {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
serde::de::IgnoredAny::deserialize(d)?;
Ok(OkResp)
}
}
#[derive(Serialize)]
struct CredentialsBody {
name: String,
password: String,
}
/// Server error body: `{"error": "...", "skipped": [...]?}`.
#[derive(Deserialize, Default)]
#[derive(serde::Deserialize, Default)]
struct ErrBody {
#[serde(default)]
error: Option<String>,
@@ -119,7 +71,7 @@ struct ErrBody {
// ---------------------------------------------------------------------------
pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
request("GET", "/api/auth/me".to_string(), None::<()>)
request("GET", AUTH_ME.to_string(), None::<()>)
}
pub fn login(
@@ -128,8 +80,8 @@ pub fn login(
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"POST",
"/api/auth/login".to_string(),
Some(CredentialsBody { name, password }),
AUTH_LOGIN.to_string(),
Some(Credentials { name, password }),
)
}
@@ -139,13 +91,13 @@ pub fn setup(
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"POST",
"/api/auth/setup".to_string(),
Some(CredentialsBody { name, password }),
AUTH_SETUP.to_string(),
Some(Credentials { name, password }),
)
}
pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("POST", "/api/auth/logout".to_string(), Some(()))
request("POST", AUTH_LOGOUT.to_string(), Some(()))
}
// ---------------------------------------------------------------------------
@@ -168,7 +120,7 @@ fn share_suffix() -> String {
.lock()
.unwrap()
.as_deref()
.map(|t| format!("?share={t}"))
.map(|t| format!("{P_SHARE}={t}"))
.unwrap_or_default()
}
@@ -183,13 +135,13 @@ fn append_query(url: &str, kv: &str) -> String {
fn files_url(root_id: i64, path: &str) -> String {
let base = if path.is_empty() {
format!("/api/files/{root_id}")
format!("{FILES}/{root_id}")
} else {
let encoded: Vec<String> = path
.split('/')
.map(|s| js_sys::encode_uri_component(s).into())
.collect();
format!("/api/files/{root_id}/{}", encoded.join("/"))
format!("{FILES}/{root_id}/{}", encoded.join("/"))
};
format!("{base}{}", share_suffix())
}
@@ -206,7 +158,7 @@ pub fn mkdir(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request_no_body("POST", files_url(root_id, path))
request("POST", files_url(root_id, path), None::<()>)
}
pub fn rename_item(
@@ -219,7 +171,7 @@ pub fn rename_item(
"POST",
files_url(root_id, path),
Some(Mutation {
op: "rename".to_string(),
op: OP_RENAME.to_string(),
new_name: Some(new_name),
dst_root_id: None,
dst: None,
@@ -235,7 +187,7 @@ pub fn move_item(
dst: &str,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
mutation(root_id, path, "move", dst_root_id, dst, overwrite)
mutation(root_id, path, OP_MOVE, dst_root_id, dst, overwrite)
}
pub fn copy_item(
@@ -245,7 +197,7 @@ pub fn copy_item(
dst: &str,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
mutation(root_id, path, "copy", dst_root_id, dst, overwrite)
mutation(root_id, path, OP_COPY, dst_root_id, dst, overwrite)
}
fn mutation(
@@ -269,23 +221,11 @@ fn mutation(
)
}
#[derive(Serialize)]
struct Mutation {
op: String,
#[serde(skip_serializing_if = "Option::is_none")]
new_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
dst_root_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
dst: Option<String>,
overwrite: bool,
}
pub fn delete_item(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request_no_body("DELETE", files_url(root_id, path))
request("DELETE", files_url(root_id, path), None::<()>)
}
// ---------------------------------------------------------------------------
@@ -294,21 +234,30 @@ pub fn delete_item(
/// `...?action=download` — a single file as-is, or a folder as `format`.
pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String {
let mut base = append_query(&files_url(root_id, path), "action=download");
let mut base = append_query(
&files_url(root_id, path),
&format!("{P_ACTION}={ACTION_DOWNLOAD}"),
);
if let Some(f) = format {
base = append_query(&base, &format!("format={f}"));
base = append_query(&base, &format!("{P_FORMAT}={f}"));
}
base
}
/// `...?action=preview` — a single file, inline (native media).
pub fn preview_url(root_id: i64, path: &str) -> String {
append_query(&files_url(root_id, path), "action=preview")
append_query(
&files_url(root_id, path),
&format!("{P_ACTION}={ACTION_PREVIEW}"),
)
}
/// `...?action=content` — raw file bytes for the text preview/editor.
pub fn content_url(root_id: i64, path: &str) -> String {
append_query(&files_url(root_id, path), "action=content")
append_query(
&files_url(root_id, path),
&format!("{P_ACTION}={ACTION_CONTENT}"),
)
}
/// Fetch a file's raw text content (for the CodeMirror preview/editor).
@@ -408,7 +357,7 @@ pub async fn save_content(
) -> Result<i64, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let url = append_query(&files_url(root_id, path), "action=content");
let url = content_url(root_id, path);
let opts = web_sys::RequestInit::new();
opts.set_method("PUT");
opts.set_mode(web_sys::RequestMode::SameOrigin);
@@ -451,14 +400,9 @@ pub async fn save_content(
let js: JsValue = JsFuture::from(js)
.await
.map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
#[derive(Deserialize)]
struct SaveResp {
#[serde(default)]
mtime: Option<i64>,
}
let save: SaveResp =
serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))?;
Ok(save.mtime.unwrap_or(0))
Ok(save.mtime)
}
/// Trigger a browser download of a same-origin URL via a temporary anchor.
@@ -496,7 +440,7 @@ pub async fn upload(
dir: &str,
overwrite: bool,
parts: Vec<(String, web_sys::File)>,
) -> Result<UploadResp, ApiError> {
) -> Result<(), ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
@@ -519,7 +463,7 @@ pub async fn upload(
let url = append_query(
&files_url(root_id, dir),
&format!("overwrite={}", if overwrite { "true" } else { "false" }),
&format!("{P_OVERWRITE}={}", if overwrite { "true" } else { "false" }),
);
let headers = web_sys::Headers::new()
@@ -554,44 +498,15 @@ pub async fn upload(
skipped: body.skipped,
});
}
let js = resp.json().map_err(|e| ApiError::Net(format!("{e:?}")))?;
let js: JsValue = JsFuture::from(js)
.await
.map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
Ok(())
}
// ---------------------------------------------------------------------------
// Shares (milestone 6)
// ---------------------------------------------------------------------------
#[derive(Deserialize, Clone)]
pub struct ShareInfo {
pub id: i64,
pub token: String,
pub name: String,
pub is_file: bool,
pub writable: bool,
pub target: String,
pub created_at: String,
#[serde(default)]
pub expires_at: Option<String>,
/// The synthetic root id to use in file API calls.
#[serde(default)]
pub root_id: Option<i64>,
}
#[derive(Serialize)]
struct CreateShareBody {
root_id: i64,
path: String,
writable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
expires_at: Option<String>,
}
pub fn list_shares() -> impl std::future::Future<Output = Result<Vec<ShareInfo>, ApiError>> {
request("GET", "/api/shares".to_string(), None::<()>)
request("GET", SHARES.to_string(), None::<()>)
}
pub fn create_share(
@@ -602,8 +517,8 @@ pub fn create_share(
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request(
"POST",
"/api/shares".to_string(),
Some(CreateShareBody {
SHARES.to_string(),
Some(CreateShare {
root_id,
path: path.to_string(),
writable,
@@ -613,64 +528,24 @@ pub fn create_share(
}
pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request_no_body("DELETE", format!("/api/shares/{id}"))
request("DELETE", format!("{SHARES}/{id}"), None::<()>)
}
/// Public: resolve a share (no session required).
pub fn resolve_share(
token: &str,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request("GET", format!("/api/share/{token}"), None::<()>)
request("GET", format!("{SHARE}/{token}"), None::<()>)
}
// ---------------------------------------------------------------------------
// Admin (milestone 7): user management + settings
// ---------------------------------------------------------------------------
#[derive(Deserialize, Clone)]
pub struct AdminUser {
pub id: i64,
pub name: String,
pub is_admin: bool,
pub active: bool,
pub roots: Vec<RootInfo>,
}
#[derive(Serialize)]
struct AdminRootBody {
path: String,
mode: String,
}
#[derive(Serialize)]
struct CreateAdminUserBody {
name: String,
password: String,
is_admin: bool,
roots: Vec<AdminRootBody>,
}
#[derive(Serialize)]
struct UpdateAdminUserBody {
#[serde(skip_serializing_if = "Option::is_none")]
password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
is_admin: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
active: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
roots: Option<Vec<AdminRootBody>>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct AdminSettings {
pub allow_writable_shares: bool,
}
fn roots_to_bodies(roots: &[(String, String)]) -> Vec<AdminRootBody> {
fn roots_to_bodies(roots: &[(String, String)]) -> Vec<Root> {
roots
.iter()
.map(|(path, mode)| AdminRootBody {
.map(|(path, mode)| Root {
path: path.clone(),
mode: mode.clone(),
})
@@ -678,7 +553,7 @@ fn roots_to_bodies(roots: &[(String, String)]) -> Vec<AdminRootBody> {
}
pub fn list_admin_users() -> impl std::future::Future<Output = Result<Vec<AdminUser>, ApiError>> {
request("GET", "/api/admin/users".to_string(), None::<()>)
request("GET", ADMIN_USERS.to_string(), None::<()>)
}
pub fn create_admin_user(
@@ -689,8 +564,8 @@ pub fn create_admin_user(
) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
request(
"POST",
"/api/admin/users".to_string(),
Some(CreateAdminUserBody {
ADMIN_USERS.to_string(),
Some(CreateUser {
name: name.to_string(),
password: password.to_string(),
is_admin,
@@ -708,8 +583,8 @@ pub fn update_admin_user(
) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
request(
"PUT",
format!("/api/admin/users/{id}"),
Some(UpdateAdminUserBody {
format!("{ADMIN_USERS}/{id}"),
Some(UpdateUser {
password,
is_admin,
active,
@@ -719,20 +594,20 @@ pub fn update_admin_user(
}
pub fn delete_admin_user(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request_no_body("DELETE", format!("/api/admin/users/{id}"))
request("DELETE", format!("{ADMIN_USERS}/{id}"), None::<()>)
}
pub fn get_admin_settings() -> impl std::future::Future<Output = Result<AdminSettings, ApiError>> {
request("GET", "/api/admin/settings".to_string(), None::<()>)
pub fn get_admin_settings() -> impl std::future::Future<Output = Result<Settings, ApiError>> {
request("GET", ADMIN_SETTINGS.to_string(), None::<()>)
}
pub fn update_admin_settings(
allow_writable_shares: bool,
) -> impl std::future::Future<Output = Result<AdminSettings, ApiError>> {
) -> impl std::future::Future<Output = Result<Settings, ApiError>> {
request(
"PUT",
"/api/admin/settings".to_string(),
Some(AdminSettings {
ADMIN_SETTINGS.to_string(),
Some(Settings {
allow_writable_shares,
}),
)
@@ -841,16 +716,6 @@ async fn request<T: DeserializeOwned>(
do_fetch(window, url, opts).await
}
/// Request without a body (mkdir / delete).
async fn request_no_body<T: DeserializeOwned>(method: &str, url: String) -> Result<T, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let opts = web_sys::RequestInit::new();
opts.set_method(method);
opts.set_mode(web_sys::RequestMode::SameOrigin);
do_fetch(window, url, opts).await
}
async fn do_fetch<T: DeserializeOwned>(
window: web_sys::Window,
url: String,
Mweb/src/util.rs
@@ -1,7 +1,7 @@
//! Formatting + small browser helpers.
use chrono::{DateTime, Utc};
use leptos::prelude::*;
use wasm_bindgen::JsValue;
/// A window event listener that is removed when the current owner is disposed.
///
@@ -34,11 +34,28 @@ pub fn format_size(bytes: u64) -> String {
}
}
/// Format an RFC 3339 timestamp as "YYYY-MM-DD HH:MM" (UTC) for display.
/// Falls back to the raw string when it cannot be parsed.
pub fn format_date(rfc3339: &str) -> String {
match DateTime::parse_from_rfc3339(rfc3339) {
Ok(dt) => dt.with_timezone(&Utc).format("%Y-%m-%d %H:%M").to_string(),
Err(_) => rfc3339.to_string(),
let d = js_sys::Date::new(&JsValue::from_str(rfc3339));
if d.get_time().is_nan() {
return rfc3339.to_string();
}
fn pad(n: u32) -> String {
if n < 10 {
format!("0{n}")
} else {
n.to_string()
}
}
format!(
"{:04}-{}-{} {} {}",
d.get_utc_full_year(),
pad(d.get_utc_month() + 1),
pad(d.get_utc_date()),
pad(d.get_utc_hours()),
pad(d.get_utc_minutes()),
)
}
pub fn storage_get(key: &str) -> Option<String> {