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