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/// `GET /api/search` — name and/or content search, streamed as SSE.
24pub const SEARCH: &str = "/api/search";
25
26/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
27pub const ADMIN_USERS: &str = "/api/admin/users";
28pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
29/// Pseudo root id every signed-in admin has on the files API: the whole
30/// server root, read-only (the admin folder picker browses it). Real root
31/// ids are positive database ids. Not listed in `/me`.
32pub const ADMIN_ROOT: i64 = -1;
33
34// ---------------------------------------------------------------------------
35// Query params
36// ---------------------------------------------------------------------------
37
38/// `?action=...` on file URLs; without it the route lists the directory.
39pub const P_ACTION: &str = "action";
40pub const ACTION_DOWNLOAD: &str = "download";
41pub const ACTION_PREVIEW: &str = "preview";
42pub const ACTION_CONTENT: &str = "content";
43/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
44/// POST route also carries uploads and mutations.
45pub const ACTION_MKDIR: &str = "mkdir";
46/// `POST {FILES}/...?action=create-file` — create an empty file. Explicit
47/// like `mkdir`, for the same reason.
48pub const ACTION_CREATE_FILE: &str = "create-file";
49/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
50pub const P_FORMAT: &str = "format";
51/// `?share=<token>` — authenticate file calls with a public share token.
52pub const P_SHARE: &str = "share";
53/// Search query text (`GET {SEARCH}`).
54pub const P_Q: &str = "q";
55/// Which index to search: `name`, `content` or `both`.
56pub const P_SCOPE: &str = "scope";
57/// Root id to search; omitted = the caller's first root.
58pub const P_ROOT: &str = "root";
59/// Folder inside the root to start a search in (relative to the root);
60/// omitted or empty = the whole root.
61pub const P_PATH: &str = "path";
62/// `?overwrite=true|1` on mutations and uploads.
63pub const P_OVERWRITE: &str = "overwrite";
64
65// ---------------------------------------------------------------------------
66// Wire enums
67// ---------------------------------------------------------------------------
68
69/// Access mode of a root or a share.
70///
71/// The serde names are also the values stored in the SQLite `mode` columns,
72/// so renaming a variant would break existing databases. The round-trip test
73/// below pins them.
74#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
75#[serde(rename_all = "lowercase")]
76pub enum Mode {
77 Rw,
78 Ro,
79}
80
81impl Mode {
82 /// The only question callers ask: may this root be written to?
83 pub fn is_writable(self) -> bool {
84 matches!(self, Mode::Rw)
85 }
86
87 /// The wire/database spelling, for `<select>` values and SQL params.
88 pub fn as_str(self) -> &'static str {
89 match self {
90 Mode::Rw => "rw",
91 Mode::Ro => "ro",
92 }
93 }
94
95 /// Parse the wire spelling. `None` for anything else.
96 pub fn from_wire(s: &str) -> Option<Self> {
97 match s {
98 "rw" => Some(Mode::Rw),
99 "ro" => Some(Mode::Ro),
100 _ => None,
101 }
102 }
103}
104
105/// Which mutation [`Mutation`] asks for.
106#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
107#[serde(rename_all = "lowercase")]
108pub enum Op {
109 Rename,
110 Move,
111 Copy,
112}
113
114// ---------------------------------------------------------------------------
115// Request bodies (client → server)
116// ---------------------------------------------------------------------------
117
118#[derive(Serialize, Deserialize)]
119pub struct Credentials {
120 pub name: String,
121 pub password: String,
122}
123
124/// Rename / move / copy (one body for all file mutations).
125#[derive(Serialize, Deserialize)]
126pub struct Mutation {
127 pub op: Op,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub new_name: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub dst_root_id: Option<i64>,
132 /// Destination directory, relative to `dst_root_id`.
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub dst: Option<String>,
135 #[serde(default)]
136 pub overwrite: bool,
137}
138
139/// A user folder: path relative to the server root + access mode.
140#[derive(Serialize, Deserialize)]
141pub struct Root {
142 /// Path relative to the server root; "." means the whole root.
143 pub path: String,
144 #[serde(default = "default_rw")]
145 pub mode: Mode,
146}
147
148fn default_rw() -> Mode {
149 Mode::Rw
150}
151
152#[derive(Serialize, Deserialize)]
153pub struct CreateUser {
154 pub name: String,
155 pub password: String,
156 #[serde(default)]
157 pub is_admin: bool,
158 #[serde(default)]
159 pub roots: Vec<Root>,
160}
161
162#[derive(Serialize, Deserialize)]
163pub struct UpdateUser {
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub password: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub is_admin: Option<bool>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub active: Option<bool>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub roots: Option<Vec<Root>>,
172}
173
174/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
175#[derive(Serialize, Deserialize, Clone, Copy)]
176pub struct Settings {
177 pub allow_writable_shares: bool,
178}
179
180#[derive(Serialize, Deserialize)]
181pub struct CreateShare {
182 pub root_id: i64,
183 /// Item path relative to the root ("" or "." for the root itself).
184 pub path: String,
185 #[serde(default)]
186 pub writable: bool,
187 /// Absolute expiry as RFC 3339; absent = never.
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub expires_at: Option<String>,
190}
191
192// ---------------------------------------------------------------------------
193// Responses (server → client)
194// ---------------------------------------------------------------------------
195
196/// What a listing entry actually is, decided by the server from the file's
197/// leading bytes (magic numbers via `infer`, plus a text/binary heuristic) —
198/// not from its name. Drives the icon and the preview the client offers.
199///
200/// Deliberately coarse: this answers "which viewer opens this", not "what
201/// exact format is it". Syntax highlighting still keys off the extension,
202/// because `.h` is C or C++ and no amount of sniffing decides that.
203#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
204#[serde(rename_all = "lowercase")]
205pub enum FileKind {
206 Dir,
207 Image,
208 Video,
209 Audio,
210 Pdf,
211 Archive,
212 /// Anything that decodes as text: source code, markup, config, plain text.
213 Text,
214 /// Recognized-but-not-viewable, or undecodable bytes.
215 Binary,
216}
217
218#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
219pub struct Entry {
220 pub name: String,
221 pub is_dir: bool,
222 pub size: u64,
223 /// RFC 3339 UTC modification time.
224 pub mtime: String,
225 /// Content-sniffed kind (see [`FileKind`]).
226 pub kind: FileKind,
227}
228
229#[derive(Serialize, Deserialize)]
230pub struct FilesResp {
231 pub entries: Vec<Entry>,
232 /// True when the folder had more entries than `MAX_LIST_ENTRIES`.
233 /// `entries` then holds only the first `MAX_LIST_ENTRIES` after sorting.
234 #[serde(default)]
235 pub truncated: bool,
236}
237
238/// Cap on entries in one directory listing.
239pub const MAX_LIST_ENTRIES: usize = 10_000;
240
241/// One streamed search result. Each is sent as one SSE event
242/// (`data: <json>`), in the order found; the `done` event always ends the
243/// stream.
244#[derive(Serialize, Deserialize, Clone)]
245#[serde(tag = "type", rename_all = "snake_case")]
246pub enum SearchEvent {
247 /// A file or folder whose name matched (scope `name`/`both`).
248 File {
249 root_id: i64,
250 /// Path relative to the root, `/`-separated.
251 path: String,
252 size: u64,
253 is_dir: bool,
254 /// Sniffed the same way as a directory listing's, so the client can
255 /// pick an icon and a viewer without a second guess at the name.
256 kind: FileKind,
257 },
258 /// One matching line (scope `content`/`both`). `path` is relative to the
259 /// root; `text` is the matched line, truncated to a fixed length.
260 Match {
261 root_id: i64,
262 path: String,
263 line: u64,
264 text: String,
265 },
266 /// Stream finished. `stopped` is true when the client aborted before the
267 /// search completed.
268 Done {
269 stopped: bool,
270 files: usize,
271 matches: usize,
272 /// Files examined (walked) before the stream ended.
273 scanned: usize,
274 /// Files skipped for content search (over the size cap).
275 skipped: usize,
276 elapsed_ms: u64,
277 },
278}
279
280#[derive(Serialize, Deserialize, Clone)]
281pub struct UserInfo {
282 pub id: i64,
283 pub name: String,
284 pub is_admin: bool,
285 /// Profile setting: single click opens entries (off = click selects,
286 /// double click opens).
287 pub single_click_open: bool,
288 /// Preferred UI language tag ("en", "de", "fr"); None = follow the
289 /// browser.
290 pub language: Option<String>,
291}
292
293#[derive(Serialize, Deserialize, Clone)]
294pub struct RootInfo {
295 pub id: i64,
296 pub name: String,
297 pub path: String,
298 pub mode: Mode,
299}
300
301/// GET `{AUTH_ME}`.
302#[derive(Serialize, Deserialize, Clone)]
303pub struct Me {
304 /// True while no users exist yet (first-boot setup).
305 pub first_boot: bool,
306 /// None on first boot.
307 pub user: Option<UserInfo>,
308 pub roots: Vec<RootInfo>,
309 pub allow_writable_shares: bool,
310}
311
312/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
313#[derive(Serialize, Deserialize, Clone)]
314pub struct ShareInfo {
315 pub id: i64,
316 pub token: String,
317 /// Display name (file/folder name, or the root's name for ".").
318 pub name: String,
319 pub is_file: bool,
320 pub writable: bool,
321 /// Path relative to the server root.
322 pub target: String,
323 /// RFC 3339 UTC creation time.
324 pub created_at: String,
325 /// RFC 3339 UTC expiry; None = never.
326 pub expires_at: Option<String>,
327 /// Synthetic root id to use in file API calls.
328 pub root_id: i64,
329 /// The file's kind for file shares (None for folder shares, and when
330 /// not sniffed — the public resolve endpoint fills it in).
331 pub kind: Option<FileKind>,
332}
333
334/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
335#[derive(Serialize, Deserialize, Clone)]
336pub struct AdminUser {
337 pub id: i64,
338 pub name: String,
339 pub is_admin: bool,
340 pub active: bool,
341 pub roots: Vec<RootInfo>,
342}
343
344/// Acknowledges a successful mutation: `{"ok": true}`.
345#[derive(Serialize, Deserialize)]
346pub struct OkResp {
347 pub ok: bool,
348}
349
350/// DELETE `{FILES}/...`: whether the removed item was a folder (the client
351/// reports "folder deleted" vs "file deleted").
352#[derive(Serialize, Deserialize)]
353pub struct DeleteResp {
354 pub ok: bool,
355 pub is_dir: bool,
356}
357
358/// Upload success: `{"ok": true, "uploaded": n}`.
359#[derive(Serialize, Deserialize)]
360pub struct UploadResp {
361 pub ok: bool,
362 pub uploaded: usize,
363}
364
365/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
366#[derive(Serialize, Deserialize)]
367pub struct SaveResp {
368 pub ok: bool,
369 pub mtime: i64,
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 /// The serde spellings are the database values too, so they are pinned.
377 #[test]
378 fn mode_wire_format_is_rw_ro() {
379 assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
380 assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
381 for m in [Mode::Rw, Mode::Ro] {
382 let s = serde_json::to_string(&m).unwrap();
383 assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
384 // `as_str`/`from_wire` must agree with serde.
385 assert_eq!(s, format!("\"{}\"", m.as_str()));
386 assert_eq!(Mode::from_wire(m.as_str()), Some(m));
387 }
388 assert_eq!(Mode::from_wire("both"), None);
389 assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
390 assert!(Mode::Rw.is_writable());
391 assert!(!Mode::Ro.is_writable());
392 }
393
394 #[test]
395 fn op_wire_format() {
396 assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
397 assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
398 assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
399 for op in [Op::Rename, Op::Move, Op::Copy] {
400 let s = serde_json::to_string(&op).unwrap();
401 assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
402 }
403 assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
404 }
405
406 #[test]
407 fn mutation_round_trip_skips_absent_fields() {
408 let m = Mutation {
409 op: Op::Move,
410 new_name: None,
411 dst_root_id: Some(3),
412 dst: Some("docs".into()),
413 overwrite: true,
414 };
415 let s = serde_json::to_string(&m).unwrap();
416 assert!(!s.contains("new_name"));
417 let back: Mutation = serde_json::from_str(&s).unwrap();
418 assert_eq!(back.dst_root_id, Some(3));
419 assert_eq!(back.op, Op::Move);
420 }
421
422 #[test]
423 fn mutation_defaults_missing_fields() {
424 let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
425 assert!(!m.overwrite);
426 assert_eq!(m.dst, None);
427 }
428
429 #[test]
430 fn root_defaults_mode_to_rw() {
431 let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
432 assert_eq!(r.mode, Mode::Rw);
433 }
434
435 #[test]
436 fn me_round_trip() {
437 let me = Me {
438 first_boot: false,
439 user: Some(UserInfo {
440 id: 1,
441 name: "admin".into(),
442 is_admin: true,
443 single_click_open: false,
444 language: None,
445 }),
446 roots: vec![RootInfo {
447 id: 1,
448 name: "root".into(),
449 path: ".".into(),
450 mode: Mode::Rw,
451 }],
452 allow_writable_shares: false,
453 };
454 let s = serde_json::to_string(&me).unwrap();
455 let back: Me = serde_json::from_str(&s).unwrap();
456 assert_eq!(back.roots.len(), 1);
457 }
458}
459