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