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