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