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