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/// Comma-separated root ids; omitted = all of the user's roots.
54pub const P_ROOTS: &str = "roots";
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 },
241 /// One matching line (scope `content`/`both`). `path` is relative to the
242 /// root; `text` is the matched line, truncated to a fixed length.
243 Match {
244 root_id: i64,
245 path: String,
246 line: u64,
247 text: String,
248 },
249 /// Stream finished. `stopped` is true when the client aborted before the
250 /// search completed.
251 Done {
252 stopped: bool,
253 files: usize,
254 matches: usize,
255 /// Files examined (walked) before the stream ended.
256 scanned: usize,
257 /// Files skipped for content search (over the size cap).
258 skipped: usize,
259 elapsed_ms: u64,
260 },
261}
262
263#[derive(Serialize, Deserialize, Clone)]
264pub struct UserInfo {
265 pub id: i64,
266 pub name: String,
267 pub is_admin: bool,
268 /// Profile setting: single click opens entries (off = click selects,
269 /// double click opens).
270 pub single_click_open: bool,
271 /// Preferred UI language tag ("en", "de", "fr"); None = follow the
272 /// browser.
273 pub language: Option<String>,
274}
275
276#[derive(Serialize, Deserialize, Clone)]
277pub struct RootInfo {
278 pub id: i64,
279 pub name: String,
280 pub path: String,
281 pub mode: Mode,
282}
283
284/// GET `{AUTH_ME}`.
285#[derive(Serialize, Deserialize, Clone)]
286pub struct Me {
287 /// True while no users exist yet (first-boot setup).
288 pub first_boot: bool,
289 /// None on first boot.
290 pub user: Option<UserInfo>,
291 pub roots: Vec<RootInfo>,
292 pub allow_writable_shares: bool,
293}
294
295/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
296#[derive(Serialize, Deserialize, Clone)]
297pub struct ShareInfo {
298 pub id: i64,
299 pub token: String,
300 /// Display name (file/folder name, or the root's name for ".").
301 pub name: String,
302 pub is_file: bool,
303 pub writable: bool,
304 /// Path relative to the server root.
305 pub target: String,
306 /// RFC 3339 UTC creation time.
307 pub created_at: String,
308 /// RFC 3339 UTC expiry; None = never.
309 pub expires_at: Option<String>,
310 /// Synthetic root id to use in file API calls.
311 pub root_id: i64,
312 /// The file's kind for file shares (None for folder shares, and when
313 /// not sniffed — the public resolve endpoint fills it in).
314 pub kind: Option<FileKind>,
315}
316
317/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
318#[derive(Serialize, Deserialize, Clone)]
319pub struct AdminUser {
320 pub id: i64,
321 pub name: String,
322 pub is_admin: bool,
323 pub active: bool,
324 pub roots: Vec<RootInfo>,
325}
326
327/// Acknowledges a successful mutation: `{"ok": true}`.
328#[derive(Serialize, Deserialize)]
329pub struct OkResp {
330 pub ok: bool,
331}
332
333/// Upload success: `{"ok": true, "uploaded": n}`.
334#[derive(Serialize, Deserialize)]
335pub struct UploadResp {
336 pub ok: bool,
337 pub uploaded: usize,
338}
339
340/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
341#[derive(Serialize, Deserialize)]
342pub struct SaveResp {
343 pub ok: bool,
344 pub mtime: i64,
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 /// The serde spellings are the database values too, so they are pinned.
352 #[test]
353 fn mode_wire_format_is_rw_ro() {
354 assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
355 assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
356 for m in [Mode::Rw, Mode::Ro] {
357 let s = serde_json::to_string(&m).unwrap();
358 assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
359 // `as_str`/`from_wire` must agree with serde.
360 assert_eq!(s, format!("\"{}\"", m.as_str()));
361 assert_eq!(Mode::from_wire(m.as_str()), Some(m));
362 }
363 assert_eq!(Mode::from_wire("both"), None);
364 assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
365 assert!(Mode::Rw.is_writable());
366 assert!(!Mode::Ro.is_writable());
367 }
368
369 #[test]
370 fn op_wire_format() {
371 assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
372 assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
373 assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
374 for op in [Op::Rename, Op::Move, Op::Copy] {
375 let s = serde_json::to_string(&op).unwrap();
376 assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
377 }
378 assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
379 }
380
381 #[test]
382 fn mutation_round_trip_skips_absent_fields() {
383 let m = Mutation {
384 op: Op::Move,
385 new_name: None,
386 dst_root_id: Some(3),
387 dst: Some("docs".into()),
388 overwrite: true,
389 };
390 let s = serde_json::to_string(&m).unwrap();
391 assert!(!s.contains("new_name"));
392 let back: Mutation = serde_json::from_str(&s).unwrap();
393 assert_eq!(back.dst_root_id, Some(3));
394 assert_eq!(back.op, Op::Move);
395 }
396
397 #[test]
398 fn mutation_defaults_missing_fields() {
399 let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
400 assert!(!m.overwrite);
401 assert_eq!(m.dst, None);
402 }
403
404 #[test]
405 fn root_defaults_mode_to_rw() {
406 let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
407 assert_eq!(r.mode, Mode::Rw);
408 }
409
410 #[test]
411 fn me_round_trip() {
412 let me = Me {
413 first_boot: false,
414 user: Some(UserInfo {
415 id: 1,
416 name: "admin".into(),
417 is_admin: true,
418 single_click_open: false,
419 language: None,
420 }),
421 roots: vec![RootInfo {
422 id: 1,
423 name: "root".into(),
424 path: ".".into(),
425 mode: Mode::Rw,
426 }],
427 allow_writable_shares: false,
428 };
429 let s = serde_json::to_string(&me).unwrap();
430 let back: Me = serde_json::from_str(&s).unwrap();
431 assert_eq!(back.roots.len(), 1);
432 }
433}
434