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