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