lib.rs
⎇
Raw
1//! The HTTP wire contract of filebrowser-ng in one place.
2//!
3//! Both the server (axum) and the web frontend (wasm `fetch`) import these
4//! endpoint paths, query params and serde types, so the two sides cannot
5//! drift apart. Serde only — no axum, no wasm dependencies.
6
7use serde::{Deserialize, Serialize};
8
9// ---------------------------------------------------------------------------
10// Endpoint paths (single source of truth for the route table and the client)
11// ---------------------------------------------------------------------------
12
13pub const AUTH_LOGIN: &str = "/api/auth/login";
14pub const AUTH_LOGOUT: &str = "/api/auth/logout";
15pub const AUTH_ME: &str = "/api/auth/me";
16pub const AUTH_SETUP: &str = "/api/auth/setup";
17/// File operations: `{FILES}/{root_id}` and `{FILES}/{root_id}/{path...}`.
18pub const FILES: &str = "/api/files";
19/// Share management (authenticated): `{SHARES}` and `{SHARES}/{id}`.
20pub const SHARES: &str = "/api/shares";
21/// Public share resolve (no login): `{SHARE}/{token}`.
22pub const SHARE: &str = "/api/share";
23/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
24pub const ADMIN_USERS: &str = "/api/admin/users";
25pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
26
27// ---------------------------------------------------------------------------
28// Query params
29// ---------------------------------------------------------------------------
30
31/// `?action=...` on file URLs; without it the route lists the directory.
32pub const P_ACTION: &str = "action";
33pub const ACTION_DOWNLOAD: &str = "download";
34pub const ACTION_PREVIEW: &str = "preview";
35pub const ACTION_CONTENT: &str = "content";
36/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
37/// POST route also carries uploads and mutations.
38pub const ACTION_MKDIR: &str = "mkdir";
39/// `POST {FILES}/...?action=create-file` — create an empty file. Explicit
40/// like `mkdir`, for the same reason.
41pub const ACTION_CREATE_FILE: &str = "create-file";
42/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
43pub const P_FORMAT: &str = "format";
44/// `?share=<token>` — authenticate file calls with a public share token.
45pub const P_SHARE: &str = "share";
46/// `?overwrite=true|1` on mutations and uploads.
47pub const P_OVERWRITE: &str = "overwrite";
48
49// ---------------------------------------------------------------------------
50// Wire enums
51// ---------------------------------------------------------------------------
52
53/// Access mode of a root or a share.
54///
55/// The serde names are also the values stored in the SQLite `mode` columns,
56/// so renaming a variant would break existing databases. The round-trip test
57/// below pins them.
58#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
59#[serde(rename_all = "lowercase")]
60pub enum Mode {
61 Rw,
62 Ro,
63}
64
65impl Mode {
66 /// The only question callers ask: may this root be written to?
67 pub fn is_writable(self) -> bool {
68 matches!(self, Mode::Rw)
69 }
70
71 /// The wire/database spelling, for `<select>` values and SQL params.
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Mode::Rw => "rw",
75 Mode::Ro => "ro",
76 }
77 }
78
79 /// Parse the wire spelling. `None` for anything else.
80 pub fn from_wire(s: &str) -> Option<Self> {
81 match s {
82 "rw" => Some(Mode::Rw),
83 "ro" => Some(Mode::Ro),
84 _ => None,
85 }
86 }
87}
88
89/// Which mutation [`Mutation`] asks for.
90#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
91#[serde(rename_all = "lowercase")]
92pub enum Op {
93 Rename,
94 Move,
95 Copy,
96}
97
98// ---------------------------------------------------------------------------
99// Request bodies (client → server)
100// ---------------------------------------------------------------------------
101
102#[derive(Serialize, Deserialize)]
103pub struct Credentials {
104 pub name: String,
105 pub password: String,
106}
107
108/// Rename / move / copy (one body for all file mutations).
109#[derive(Serialize, Deserialize)]
110pub struct Mutation {
111 pub op: Op,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub new_name: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub dst_root_id: Option<i64>,
116 /// Destination directory, relative to `dst_root_id`.
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub dst: Option<String>,
119 #[serde(default)]
120 pub overwrite: bool,
121}
122
123/// A user folder: path relative to the server root + access mode.
124#[derive(Serialize, Deserialize)]
125pub struct Root {
126 /// Path relative to the server root; "." means the whole root.
127 pub path: String,
128 #[serde(default = "default_rw")]
129 pub mode: Mode,
130}
131
132fn default_rw() -> Mode {
133 Mode::Rw
134}
135
136#[derive(Serialize, Deserialize)]
137pub struct CreateUser {
138 pub name: String,
139 pub password: String,
140 #[serde(default)]
141 pub is_admin: bool,
142 #[serde(default)]
143 pub roots: Vec<Root>,
144}
145
146#[derive(Serialize, Deserialize)]
147pub struct UpdateUser {
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub password: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub is_admin: Option<bool>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub active: Option<bool>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub roots: Option<Vec<Root>>,
156}
157
158/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
159#[derive(Serialize, Deserialize, Clone, Copy)]
160pub struct Settings {
161 pub allow_writable_shares: bool,
162}
163
164#[derive(Serialize, Deserialize)]
165pub struct CreateShare {
166 pub root_id: i64,
167 /// Item path relative to the root ("" or "." for the root itself).
168 pub path: String,
169 #[serde(default)]
170 pub writable: bool,
171 /// Absolute expiry as RFC 3339; absent = never.
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub expires_at: Option<String>,
174}
175
176// ---------------------------------------------------------------------------
177// Responses (server → client)
178// ---------------------------------------------------------------------------
179
180/// What a listing entry actually is, decided by the server from the file's
181/// leading bytes (magic numbers via `infer`, plus a text/binary heuristic) —
182/// not from its name. Drives the icon and the preview the client offers.
183///
184/// Deliberately coarse: this answers "which viewer opens this", not "what
185/// exact format is it". Syntax highlighting still keys off the extension,
186/// because `.h` is C or C++ and no amount of sniffing decides that.
187#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
188#[serde(rename_all = "lowercase")]
189pub enum FileKind {
190 Dir,
191 Image,
192 Video,
193 Audio,
194 Pdf,
195 Archive,
196 /// Anything that decodes as text: source code, markup, config, plain text.
197 Text,
198 /// Recognized-but-not-viewable, or undecodable bytes.
199 Binary,
200}
201
202#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
203pub struct Entry {
204 pub name: String,
205 pub is_dir: bool,
206 pub size: u64,
207 /// RFC 3339 UTC modification time.
208 pub mtime: String,
209 /// Content-sniffed kind (see [`FileKind`]).
210 pub kind: FileKind,
211}
212
213#[derive(Serialize, Deserialize)]
214pub struct FilesResp {
215 pub entries: Vec<Entry>,
216}
217
218#[derive(Serialize, Deserialize, Clone)]
219pub struct UserInfo {
220 pub id: i64,
221 pub name: String,
222 pub is_admin: bool,
223 /// Profile setting: single click opens entries (off = click selects,
224 /// double click opens).
225 pub single_click_open: bool,
226 /// Preferred UI language tag ("en", "de", "fr"); None = follow the
227 /// browser.
228 pub language: Option<String>,
229}
230
231#[derive(Serialize, Deserialize, Clone)]
232pub struct RootInfo {
233 pub id: i64,
234 pub name: String,
235 pub path: String,
236 pub mode: Mode,
237}
238
239/// GET `{AUTH_ME}`.
240#[derive(Serialize, Deserialize, Clone)]
241pub struct Me {
242 /// True while no users exist yet (first-boot setup).
243 pub first_boot: bool,
244 /// None on first boot.
245 pub user: Option<UserInfo>,
246 pub roots: Vec<RootInfo>,
247 pub allow_writable_shares: bool,
248}
249
250/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
251#[derive(Serialize, Deserialize, Clone)]
252pub struct ShareInfo {
253 pub id: i64,
254 pub token: String,
255 /// Display name (file/folder name, or the root's name for ".").
256 pub name: String,
257 pub is_file: bool,
258 pub writable: bool,
259 /// Path relative to the server root.
260 pub target: String,
261 /// RFC 3339 UTC creation time.
262 pub created_at: String,
263 /// RFC 3339 UTC expiry; None = never.
264 pub expires_at: Option<String>,
265 /// Synthetic root id to use in file API calls.
266 pub root_id: i64,
267 /// The file's kind for file shares (None for folder shares, and when
268 /// not sniffed — the public resolve endpoint fills it in).
269 pub kind: Option<FileKind>,
270}
271
272/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
273#[derive(Serialize, Deserialize, Clone)]
274pub struct AdminUser {
275 pub id: i64,
276 pub name: String,
277 pub is_admin: bool,
278 pub active: bool,
279 pub roots: Vec<RootInfo>,
280}
281
282/// Acknowledges a successful mutation: `{"ok": true}`.
283#[derive(Serialize, Deserialize)]
284pub struct OkResp {
285 pub ok: bool,
286}
287
288/// Upload success: `{"ok": true, "uploaded": n}`.
289#[derive(Serialize, Deserialize)]
290pub struct UploadResp {
291 pub ok: bool,
292 pub uploaded: usize,
293}
294
295/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
296#[derive(Serialize, Deserialize)]
297pub struct SaveResp {
298 pub ok: bool,
299 pub mtime: i64,
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 /// The serde spellings are the database values too, so they are pinned.
307 #[test]
308 fn mode_wire_format_is_rw_ro() {
309 assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
310 assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
311 for m in [Mode::Rw, Mode::Ro] {
312 let s = serde_json::to_string(&m).unwrap();
313 assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
314 // `as_str`/`from_wire` must agree with serde.
315 assert_eq!(s, format!("\"{}\"", m.as_str()));
316 assert_eq!(Mode::from_wire(m.as_str()), Some(m));
317 }
318 assert_eq!(Mode::from_wire("both"), None);
319 assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
320 assert!(Mode::Rw.is_writable());
321 assert!(!Mode::Ro.is_writable());
322 }
323
324 #[test]
325 fn op_wire_format() {
326 assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
327 assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
328 assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
329 for op in [Op::Rename, Op::Move, Op::Copy] {
330 let s = serde_json::to_string(&op).unwrap();
331 assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
332 }
333 assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
334 }
335
336 #[test]
337 fn mutation_round_trip_skips_absent_fields() {
338 let m = Mutation {
339 op: Op::Move,
340 new_name: None,
341 dst_root_id: Some(3),
342 dst: Some("docs".into()),
343 overwrite: true,
344 };
345 let s = serde_json::to_string(&m).unwrap();
346 assert!(!s.contains("new_name"));
347 let back: Mutation = serde_json::from_str(&s).unwrap();
348 assert_eq!(back.dst_root_id, Some(3));
349 assert_eq!(back.op, Op::Move);
350 }
351
352 #[test]
353 fn mutation_defaults_missing_fields() {
354 let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
355 assert!(!m.overwrite);
356 assert_eq!(m.dst, None);
357 }
358
359 #[test]
360 fn root_defaults_mode_to_rw() {
361 let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
362 assert_eq!(r.mode, Mode::Rw);
363 }
364
365 #[test]
366 fn me_round_trip() {
367 let me = Me {
368 first_boot: false,
369 user: Some(UserInfo {
370 id: 1,
371 name: "admin".into(),
372 is_admin: true,
373 single_click_open: false,
374 language: None,
375 }),
376 roots: vec![RootInfo {
377 id: 1,
378 name: "root".into(),
379 path: ".".into(),
380 mode: Mode::Rw,
381 }],
382 allow_writable_shares: false,
383 };
384 let s = serde_json::to_string(&me).unwrap();
385 let back: Me = serde_json::from_str(&s).unwrap();
386 assert_eq!(back.roots.len(), 1);
387 }
388}
389