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