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/// Change or set the signed-in user's password (`POST`), or remove it
18/// (`DELETE`, passkey-only accounts).
19pub const AUTH_PASSWORD: &str = "/api/auth/password";
20/// `PUT` the signed-in user's sign-in requirement ([`AuthMode`]).
21pub const AUTH_MODE: &str = "/api/auth/mode";
22/// The signed-in user's passkeys: `GET {AUTH_PASSKEYS}` lists them,
23/// `DELETE {AUTH_PASSKEYS}/{id}` removes one.
24pub const AUTH_PASSKEYS: &str = "/api/auth/passkeys";
25/// Start registering a new passkey (`POST`). Finished at
26/// `{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
27pub const AUTH_PASSKEYS_REGISTER: &str = "/api/auth/passkeys/register";
28/// Start a passkey sign-in (`POST`, no session needed). Finished at
29/// `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
30pub const AUTH_PASSKEY_LOGIN: &str = "/api/auth/passkey/login";
31/// The signed-in user's WebDAV app passwords: `GET {AUTH_APP_PASSWORDS}`
32/// lists them, `POST` creates one, `DELETE {AUTH_APP_PASSWORDS}/{id}` revokes
33/// one.
34pub const AUTH_APP_PASSWORDS: &str = "/api/auth/app-passwords";
35/// Second leg of both WebAuthn ceremonies: the browser's answer goes to the
36/// begin path plus this suffix.
37pub const FINISH_SUFFIX: &str = "/finish";
38/// File operations: `{FILES}/{root_id}` and `{FILES}/{root_id}/{path...}`.
39pub const FILES: &str = "/api/files";
40/// Share management (authenticated): `{SHARES}` and `{SHARES}/{id}`.
41pub const SHARES: &str = "/api/shares";
42/// Public share resolve (no login): `{SHARE}/{token}`.
43pub const SHARE: &str = "/api/share";
44/// Suffix on `{SHARE}/{token}`: submit the password of a protected share.
45pub const SHARE_UNLOCK_SUFFIX: &str = "/unlock";
46/// `GET /api/search` — name and/or content search, streamed as SSE.
47pub const SEARCH: &str = "/api/search";
48
49/// WebDAV mount of the signed-in user's roots: `{DAV}` and `{DAV}/{path...}`.
50pub const DAV: &str = "/dav";
51/// WebDAV mount of one public share: `{DAV_SHARE}/{token}/{path...}`.
52///
53/// A separate top-level path, not a segment under [`DAV`]: there, the first
54/// segment is a root's display name, which a reserved word could collide with.
55pub const DAV_SHARE: &str = "/dav-share";
56
57/// CalDAV and CardDAV: principals, calendars and address books.
58///
59/// Not under [`DAV`] for the same reason as [`DAV_SHARE`].
60pub const PIM: &str = "/pim";
61/// RFC 6764 discovery. Both redirect to [`PIM`].
62pub const WELL_KNOWN_CALDAV: &str = "/.well-known/caldav";
63pub const WELL_KNOWN_CARDDAV: &str = "/.well-known/carddav";
64
65/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
66pub const ADMIN_USERS: &str = "/api/admin/users";
67/// Admin view of every share on the server: `{ADMIN_SHARES}` and
68/// `{ADMIN_SHARES}/{id}`. [`SHARES`] is the same data scoped to the caller.
69pub const ADMIN_SHARES: &str = "/api/admin/shares";
70pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
71/// Admin management of rooms and resources: `{ADMIN_ROOMS}` and
72/// `{ADMIN_ROOMS}/{id}`.
73pub const ADMIN_ROOMS: &str = "/api/admin/rooms";
74/// The signed-in user's calendars and address books, own and lent to them
75/// (`GET`). `{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` lists (`GET`) and lends
76/// (`POST`) an own one; `DELETE` on `.../{user_id}` below it ends a loan.
77pub const PIM_COLLECTIONS: &str = "/api/pim/collections";
78pub const SHARES_SUFFIX: &str = "/shares";
79/// Pseudo root id every signed-in admin has on the files API: the whole
80/// server root, read-only (the admin folder picker browses it). Real root
81/// ids are positive database ids. Not listed in `/me`.
82pub const ADMIN_ROOT: i64 = -1;
83
84// ---------------------------------------------------------------------------
85// Query params
86// ---------------------------------------------------------------------------
87
88/// `?action=...` on file URLs; without it the route lists the directory.
89pub const P_ACTION: &str = "action";
90pub const ACTION_DOWNLOAD: &str = "download";
91pub const ACTION_PREVIEW: &str = "preview";
92pub const ACTION_CONTENT: &str = "content";
93pub const ACTION_THUMB: &str = "thumb";
94/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
95/// POST route also carries uploads and mutations.
96pub const ACTION_MKDIR: &str = "mkdir";
97/// `POST {FILES}/...?action=create-file` — create an empty file. Explicit
98/// like `mkdir`, for the same reason.
99pub const ACTION_CREATE_FILE: &str = "create-file";
100/// `POST {FILES}/...?action=exists` with an [`ExistsReq`] body — read-only
101/// pre-check for an upload: which of the given targets already exist.
102pub const ACTION_EXISTS: &str = "exists";
103/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
104pub const P_FORMAT: &str = "format";
105/// `?share=<token>` — authenticate file calls with a public share token.
106pub const P_SHARE: &str = "share";
107/// Search query text (`GET {SEARCH}`).
108pub const P_Q: &str = "q";
109/// Which index to search: `name`, `content` or `both`.
110pub const P_SCOPE: &str = "scope";
111/// Root id to search; omitted = the caller's first root.
112pub const P_ROOT: &str = "root";
113/// Folder inside the root to start a search in (relative to the root);
114/// omitted or empty = the whole root.
115pub const P_PATH: &str = "path";
116/// `?overwrite=true|1` on mutations and uploads.
117pub const P_OVERWRITE: &str = "overwrite";
118/// Listing order ([`SortKey`]); omitted = by name.
119pub const P_SORT: &str = "sort";
120/// `?desc=true` reverses the listing order. Folders still come first.
121pub const P_DESC: &str = "desc";
122/// First listing entry to return, in the sorted order.
123pub const P_OFFSET: &str = "offset";
124/// Listing entries to return, capped at [`MAX_LIST_ENTRIES`]; omitted = the cap.
125pub const P_LIMIT: &str = "limit";
126/// `?dirs=true` lists only the subfolders (the folder picker).
127pub const P_DIRS: &str = "dirs";
128/// `?around=name` returns the page that holds this entry, instead of the
129/// one at the offset. A missing name falls back to the offset.
130pub const P_AROUND: &str = "around";
131
132// ---------------------------------------------------------------------------
133// Wire enums
134// ---------------------------------------------------------------------------
135
136/// Access mode of a root or a share.
137///
138/// The serde names are also the values stored in the SQLite `mode` columns,
139/// so renaming a variant would break existing databases. The round-trip test
140/// below pins them.
141#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
142#[serde(rename_all = "lowercase")]
143pub enum Mode {
144 Rw,
145 Ro,
146}
147
148impl Mode {
149 /// The only question callers ask: may this root be written to?
150 pub fn is_writable(self) -> bool {
151 matches!(self, Mode::Rw)
152 }
153
154 /// The wire/database spelling, for `<select>` values and SQL params.
155 pub fn as_str(self) -> &'static str {
156 match self {
157 Mode::Rw => "rw",
158 Mode::Ro => "ro",
159 }
160 }
161
162 /// Parse the wire spelling. `None` for anything else.
163 pub fn from_wire(s: &str) -> Option<Self> {
164 match s {
165 "rw" => Some(Mode::Rw),
166 "ro" => Some(Mode::Ro),
167 _ => None,
168 }
169 }
170}
171
172/// Which mutation [`Mutation`] asks for.
173#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
174#[serde(rename_all = "lowercase")]
175pub enum Op {
176 Rename,
177 Move,
178 Copy,
179}
180
181/// What a listing is ordered by ([`P_SORT`]). Folders always sort before
182/// files, so a size or date order does not scatter them through the listing.
183#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
184#[serde(rename_all = "lowercase")]
185pub enum SortKey {
186 #[default]
187 Name,
188 Size,
189 Modified,
190}
191
192impl SortKey {
193 pub fn as_str(self) -> &'static str {
194 match self {
195 SortKey::Name => "name",
196 SortKey::Size => "size",
197 SortKey::Modified => "modified",
198 }
199 }
200
201 pub fn parse(s: &str) -> Option<Self> {
202 match s {
203 "name" => Some(SortKey::Name),
204 "size" => Some(SortKey::Size),
205 "modified" => Some(SortKey::Modified),
206 _ => None,
207 }
208 }
209}
210
211// ---------------------------------------------------------------------------
212// Request bodies (client → server)
213// ---------------------------------------------------------------------------
214
215#[derive(Serialize, Deserialize)]
216pub struct Credentials {
217 pub name: String,
218 pub password: String,
219}
220
221/// Rename / move / copy (one body for all file mutations).
222#[derive(Serialize, Deserialize)]
223pub struct Mutation {
224 pub op: Op,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub new_name: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub dst_root_id: Option<i64>,
229 /// Destination directory, relative to `dst_root_id`.
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub dst: Option<String>,
232 #[serde(default)]
233 pub overwrite: bool,
234}
235
236/// A user folder: path relative to the server root + access mode.
237#[derive(Serialize, Deserialize)]
238pub struct Root {
239 /// Path relative to the server root; "." means the whole root.
240 pub path: String,
241 #[serde(default = "default_rw")]
242 pub mode: Mode,
243}
244
245fn default_rw() -> Mode {
246 Mode::Rw
247}
248
249#[derive(Serialize, Deserialize)]
250pub struct CreateUser {
251 pub name: String,
252 pub password: String,
253 #[serde(default)]
254 pub is_admin: bool,
255 #[serde(default)]
256 pub roots: Vec<Root>,
257}
258
259#[derive(Serialize, Deserialize)]
260pub struct UpdateUser {
261 /// Setting one is also the recovery path for a locked-out account: it
262 /// deletes every passkey and puts the account back on
263 /// [`AuthMode::Either`], leaving the new password as the one way in.
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub password: Option<String>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub is_admin: Option<bool>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub active: Option<bool>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub roots: Option<Vec<Root>>,
272}
273
274/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
275#[derive(Serialize, Deserialize, Clone)]
276pub struct Settings {
277 pub allow_writable_shares: bool,
278 /// Folders left out of every search, as paths relative to the server
279 /// root. A path covers everything beneath it.
280 #[serde(default)]
281 pub search_excludes: Vec<String>,
282}
283
284#[derive(Serialize, Deserialize)]
285pub struct CreateShare {
286 pub root_id: i64,
287 /// Item path relative to the root ("" or "." for the root itself).
288 pub path: String,
289 #[serde(default)]
290 pub writable: bool,
291 /// Absolute expiry as RFC 3339; absent = never.
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub expires_at: Option<String>,
294 /// Password the visitor must enter before the share opens; absent = none.
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub password: Option<String>,
297}
298
299/// POST `{SHARE}/{token}/unlock` — the password for a protected share.
300#[derive(Serialize, Deserialize)]
301pub struct UnlockShare {
302 pub password: String,
303}
304
305// ---------------------------------------------------------------------------
306// Responses (server → client)
307// ---------------------------------------------------------------------------
308
309/// What a listing entry actually is, decided by the server from the file's
310/// leading bytes (magic numbers via `infer`, plus a text/binary heuristic) —
311/// not from its name. Drives the icon and the preview the client offers.
312///
313/// Deliberately coarse: this answers "which viewer opens this", not "what
314/// exact format is it". Syntax highlighting still keys off the extension,
315/// because `.h` is C or C++ and no amount of sniffing decides that.
316#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
317#[serde(rename_all = "lowercase")]
318pub enum FileKind {
319 Dir,
320 Image,
321 Video,
322 Audio,
323 Pdf,
324 Archive,
325 /// Anything that decodes as text: source code, markup, config, plain text.
326 Text,
327 /// Recognized-but-not-viewable, or undecodable bytes.
328 Binary,
329}
330
331#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
332pub struct Entry {
333 pub name: String,
334 pub is_dir: bool,
335 pub size: u64,
336 /// RFC 3339 UTC modification time.
337 pub mtime: String,
338 /// Content-sniffed kind (see [`FileKind`]).
339 pub kind: FileKind,
340}
341
342/// One page of a folder listing.
343#[derive(Serialize, Deserialize)]
344pub struct FilesResp {
345 pub entries: Vec<Entry>,
346 /// Entries in the whole folder, across all pages.
347 pub total: usize,
348 /// Position of `entries[0]` in the sorted folder. An offset past the end
349 /// comes back as the start of the last page.
350 pub offset: usize,
351}
352
353/// Cap on entries in one listing page.
354pub const MAX_LIST_ENTRIES: usize = 10_000;
355
356/// One streamed search result. Each is sent as one SSE event
357/// (`data: <json>`), in the order found; the `done` event always ends the
358/// stream.
359#[derive(Serialize, Deserialize, Clone)]
360#[serde(tag = "type", rename_all = "snake_case")]
361pub enum SearchEvent {
362 /// A file or folder whose name matched (scope `name`/`both`).
363 File {
364 root_id: i64,
365 /// Path relative to the root, `/`-separated.
366 path: String,
367 size: u64,
368 is_dir: bool,
369 /// Sniffed the same way as a directory listing's, so the client can
370 /// pick an icon and a viewer without a second guess at the name.
371 kind: FileKind,
372 },
373 /// One matching line (scope `content`/`both`). `path` is relative to the
374 /// root; `text` is the matched line, truncated to a fixed length.
375 Match {
376 root_id: i64,
377 path: String,
378 line: u64,
379 text: String,
380 },
381 /// Stream finished. `stopped` is true when the client aborted before the
382 /// search completed.
383 Done {
384 stopped: bool,
385 /// Files examined (walked) before the stream ended.
386 scanned: usize,
387 /// Files skipped for content search (over the size cap).
388 skipped: usize,
389 elapsed_ms: u64,
390 },
391}
392
393#[derive(Serialize, Deserialize, Clone, PartialEq)]
394pub struct UserInfo {
395 pub id: i64,
396 pub name: String,
397 pub is_admin: bool,
398 /// Profile setting: single click opens entries (off = click selects,
399 /// double click opens).
400 pub single_click_open: bool,
401 /// Profile setting: show image and video thumbnails in the grid.
402 pub thumbnails: bool,
403 /// Preferred UI language tag ("en", "de", "fr"); None = follow the
404 /// browser.
405 pub language: Option<String>,
406 /// Profile setting: the root the UI opens on page load and on the home
407 /// link. Always one of `Me::roots` (the server drops a stale id), or
408 /// None for the root picker.
409 pub default_root_id: Option<i64>,
410 /// What this account needs to sign in.
411 pub auth_mode: AuthMode,
412 /// Whether a password is set at all. False means passkeys only.
413 pub has_password: bool,
414}
415
416#[derive(Serialize, Deserialize, Clone)]
417pub struct RootInfo {
418 pub id: i64,
419 pub name: String,
420 pub path: String,
421 pub mode: Mode,
422}
423
424/// GET `{AUTH_ME}`.
425#[derive(Serialize, Deserialize, Clone)]
426pub struct Me {
427 /// True while no users exist yet (first-boot setup).
428 pub first_boot: bool,
429 /// None on first boot.
430 pub user: Option<UserInfo>,
431 pub roots: Vec<RootInfo>,
432 pub allow_writable_shares: bool,
433 /// Whether the server can make thumbnails at all (`--cache` is set).
434 /// The profile setting is only offered when this is true.
435 pub thumbnails_available: bool,
436 /// `--public-url`, if set. The UI builds share links from it instead of
437 /// the page origin.
438 pub public_url: Option<String>,
439}
440
441/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
442#[derive(Serialize, Deserialize, Clone)]
443pub struct ShareInfo {
444 /// Also the share's synthetic root id in file API calls.
445 pub id: i64,
446 pub token: String,
447 /// Display name (file/folder name, or the root's name for ".").
448 pub name: String,
449 pub is_file: bool,
450 pub writable: bool,
451 /// Path relative to the server root.
452 pub target: String,
453 /// RFC 3339 UTC creation time.
454 pub created_at: String,
455 /// RFC 3339 UTC expiry; None = never.
456 pub expires_at: Option<String>,
457 /// The file's kind for file shares (None for folder shares, and when
458 /// not sniffed — the public resolve endpoint fills it in).
459 pub kind: Option<FileKind>,
460 /// Whether the share asks for a password. Never the password itself.
461 pub has_password: bool,
462}
463
464/// One share plus who owns it: GET `{ADMIN_SHARES}`.
465///
466/// Admin-only: it carries the full [`ShareInfo::token`], and a token is access.
467/// Kept separate from [`ShareInfo`] because the public resolve route answers
468/// with a `ShareInfo` to anonymous visitors.
469#[derive(Serialize, Deserialize, Clone)]
470pub struct AdminShare {
471 #[serde(flatten)]
472 pub share: ShareInfo,
473 pub creator_id: i64,
474 pub creator_name: String,
475 /// Whether the creator's account can still sign in. Deactivating an account
476 /// does not revoke its shares, so `false` marks a live link its owner can no
477 /// longer manage.
478 pub creator_active: bool,
479}
480
481/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
482#[derive(Serialize, Deserialize, Clone)]
483pub struct AdminUser {
484 pub id: i64,
485 pub name: String,
486 pub is_admin: bool,
487 pub active: bool,
488 pub roots: Vec<RootInfo>,
489}
490
491/// Acknowledges a successful mutation. Carries nothing: the 2xx status is
492/// the acknowledgement, so the body is the empty object.
493#[derive(Serialize, Deserialize)]
494pub struct OkResp {}
495
496#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
497#[serde(rename_all = "lowercase")]
498pub enum PimCollectionKind {
499 Calendar,
500 Addressbook,
501}
502
503/// How a calendar or address book is lent. The serde names are also the
504/// values stored in `pim_shares.mode`.
505#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
506pub enum PimShareMode {
507 #[serde(rename = "ro")]
508 Ro,
509 /// Change members, but send no scheduling messages as the owner.
510 #[serde(rename = "rw")]
511 Rw,
512 /// Also invite and answer as the owner, named in SENT-BY.
513 #[serde(rename = "rw+schedule")]
514 RwSchedule,
515}
516
517impl PimShareMode {
518 pub fn as_str(self) -> &'static str {
519 match self {
520 PimShareMode::Ro => "ro",
521 PimShareMode::Rw => "rw",
522 PimShareMode::RwSchedule => "rw+schedule",
523 }
524 }
525
526 pub fn from_wire(s: &str) -> Option<Self> {
527 match s {
528 "ro" => Some(PimShareMode::Ro),
529 "rw" => Some(PimShareMode::Rw),
530 "rw+schedule" => Some(PimShareMode::RwSchedule),
531 _ => None,
532 }
533 }
534}
535
536/// One entry of `GET {PIM_COLLECTIONS}`.
537#[derive(Serialize, Deserialize, Clone, Debug)]
538pub struct PimCollectionInfo {
539 pub id: i64,
540 pub kind: PimCollectionKind,
541 pub name: String,
542 /// The CalDAV or CardDAV URL, as seen by the signed-in user.
543 pub url: String,
544 pub owner: String,
545 /// `None` for an own collection, the loan's mode for a lent one.
546 pub mode: Option<PimShareMode>,
547}
548
549/// `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`.
550#[derive(Serialize, Deserialize, Clone, Debug)]
551pub struct PimShareInfo {
552 pub user_id: i64,
553 pub user_name: String,
554 pub mode: PimShareMode,
555}
556
557/// `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`: lend to an account, or
558/// change the mode of an existing loan.
559#[derive(Serialize, Deserialize)]
560pub struct CreatePimShare {
561 pub user: String,
562 pub mode: PimShareMode,
563}
564
565#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
566#[serde(rename_all = "lowercase")]
567pub enum RoomKind {
568 Room,
569 Resource,
570}
571
572/// A room or resource: `GET {ADMIN_ROOMS}`.
573#[derive(Serialize, Deserialize, Clone, Debug)]
574pub struct RoomInfo {
575 pub id: i64,
576 /// The URL segment. Fixed, since it is also the scheduling address.
577 pub name: String,
578 pub display_name: String,
579 pub kind: RoomKind,
580 /// The principal URL.
581 pub url: String,
582}
583
584/// `POST {ADMIN_ROOMS}`.
585#[derive(Serialize, Deserialize)]
586pub struct CreateRoom {
587 pub name: String,
588 /// Defaults to `name`.
589 pub display_name: Option<String>,
590 pub kind: RoomKind,
591}
592
593/// `PUT {ADMIN_ROOMS}/{id}`.
594#[derive(Serialize, Deserialize)]
595pub struct UpdateRoom {
596 pub display_name: String,
597}
598
599/// `POST ...?action=exists` body: upload targets relative to the request
600/// directory (may contain subfolders, like upload part names).
601#[derive(Serialize, Deserialize)]
602pub struct ExistsReq {
603 pub paths: Vec<String>,
604}
605
606/// One existing upload target.
607#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
608pub struct Existing {
609 pub path: String,
610 pub is_dir: bool,
611}
612
613/// `POST ...?action=exists` response: the subset of the requested paths
614/// that exist, in request order.
615#[derive(Serialize, Deserialize)]
616pub struct ExistsResp {
617 pub existing: Vec<Existing>,
618}
619
620/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
621#[derive(Serialize, Deserialize)]
622pub struct SaveResp {
623 pub mtime: i64,
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 /// The serde spellings are the database values too, so they are pinned.
631 #[test]
632 fn mode_wire_format_is_rw_ro() {
633 assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
634 assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
635 for m in [Mode::Rw, Mode::Ro] {
636 let s = serde_json::to_string(&m).unwrap();
637 assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
638 // `as_str`/`from_wire` must agree with serde.
639 assert_eq!(s, format!("\"{}\"", m.as_str()));
640 assert_eq!(Mode::from_wire(m.as_str()), Some(m));
641 }
642 assert_eq!(Mode::from_wire("both"), None);
643 assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
644 assert!(Mode::Rw.is_writable());
645 assert!(!Mode::Ro.is_writable());
646 }
647
648 #[test]
649 fn pim_share_mode_wire_format() {
650 for m in [PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule] {
651 let s = serde_json::to_string(&m).unwrap();
652 assert_eq!(s, format!("\"{}\"", m.as_str()));
653 assert_eq!(serde_json::from_str::<PimShareMode>(&s).unwrap(), m);
654 assert_eq!(PimShareMode::from_wire(m.as_str()), Some(m));
655 }
656 assert_eq!(PimShareMode::RwSchedule.as_str(), "rw+schedule");
657 }
658
659 #[test]
660 fn op_wire_format() {
661 assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
662 assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
663 assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
664 for op in [Op::Rename, Op::Move, Op::Copy] {
665 let s = serde_json::to_string(&op).unwrap();
666 assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
667 }
668 assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
669 }
670
671 #[test]
672 fn mutation_round_trip_skips_absent_fields() {
673 let m = Mutation {
674 op: Op::Move,
675 new_name: None,
676 dst_root_id: Some(3),
677 dst: Some("docs".into()),
678 overwrite: true,
679 };
680 let s = serde_json::to_string(&m).unwrap();
681 assert!(!s.contains("new_name"));
682 let back: Mutation = serde_json::from_str(&s).unwrap();
683 assert_eq!(back.dst_root_id, Some(3));
684 assert_eq!(back.op, Op::Move);
685 }
686
687 #[test]
688 fn mutation_defaults_missing_fields() {
689 let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
690 assert!(!m.overwrite);
691 assert_eq!(m.dst, None);
692 }
693
694 #[test]
695 fn root_defaults_mode_to_rw() {
696 let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
697 assert_eq!(r.mode, Mode::Rw);
698 }
699
700 #[test]
701 fn me_round_trip() {
702 let me = Me {
703 first_boot: false,
704 user: Some(UserInfo {
705 id: 1,
706 name: "admin".into(),
707 is_admin: true,
708 single_click_open: false,
709 thumbnails: true,
710 language: None,
711 default_root_id: None,
712 auth_mode: AuthMode::Either,
713 has_password: true,
714 }),
715 roots: vec![RootInfo {
716 id: 1,
717 name: "root".into(),
718 path: ".".into(),
719 mode: Mode::Rw,
720 }],
721 allow_writable_shares: false,
722 thumbnails_available: true,
723 public_url: None,
724 };
725 let s = serde_json::to_string(&me).unwrap();
726 let back: Me = serde_json::from_str(&s).unwrap();
727 assert_eq!(back.roots.len(), 1);
728 }
729}
730
731// ---------------------------------------------------------------------------
732// Sign-in methods: password, passkeys, and how they combine
733// ---------------------------------------------------------------------------
734
735/// What an account needs to sign in.
736///
737/// Not a "2FA on/off" flag: [`AuthMode::Either`] with no password is a
738/// passkey-only account, which is still two factors when the authenticator
739/// does user verification (the server always asks for it).
740#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
741#[serde(rename_all = "lowercase")]
742pub enum AuthMode {
743 /// Password *or* passkey. Either one alone signs the user in.
744 #[default]
745 Either,
746 /// Password *and* passkey. Both legs must pass, in either order.
747 Both,
748}
749
750impl AuthMode {
751 pub fn as_str(self) -> &'static str {
752 match self {
753 AuthMode::Either => "either",
754 AuthMode::Both => "both",
755 }
756 }
757
758 pub fn from_wire(s: &str) -> Option<Self> {
759 match s {
760 "either" => Some(AuthMode::Either),
761 "both" => Some(AuthMode::Both),
762 _ => None,
763 }
764 }
765}
766
767/// One registered passkey, as shown in profile settings. Never carries key
768/// material.
769#[derive(Serialize, Deserialize, Clone)]
770pub struct PasskeyInfo {
771 pub id: i64,
772 /// User-chosen label ("YubiKey", "Work laptop").
773 pub name: String,
774 /// RFC 3339 UTC.
775 pub created_at: String,
776 pub last_used_at: Option<String>,
777 /// Whether the browser reported this credential as discoverable, so it
778 /// can sign in without the account name. `None` when the browser did not
779 /// say — the `credProps` extension is optional and unsigned, so absence
780 /// means "unknown", never "no".
781 pub discoverable: Option<bool>,
782}
783
784/// One app password, as shown in profile settings.
785///
786/// WebDAV-only: it never signs in to the web UI. Never carries the secret,
787/// which exists only in [`NewAppPassword`].
788#[derive(Serialize, Deserialize, Clone)]
789pub struct AppPasswordInfo {
790 pub id: i64,
791 /// User-chosen label ("Laptop mount", "phone").
792 pub name: String,
793 /// RFC 3339 UTC.
794 pub created_at: String,
795 /// Only tracked to the hour.
796 pub last_used_at: Option<String>,
797}
798
799/// `POST {AUTH_APP_PASSWORDS}`.
800#[derive(Serialize, Deserialize)]
801pub struct CreateAppPassword {
802 pub name: String,
803}
804
805/// The answer to `POST {AUTH_APP_PASSWORDS}`.
806///
807/// The only time `secret` is readable. The server keeps a hash of it.
808#[derive(Serialize, Deserialize)]
809pub struct NewAppPassword {
810 #[serde(flatten)]
811 pub info: AppPasswordInfo,
812 pub secret: String,
813}
814
815/// `POST {AUTH_PASSWORD}` — set or change the password.
816///
817/// No current password to confirm: a passkey-only account has none to give.
818/// The session is the gate, and the server drops the account's other
819/// sessions on every change.
820#[derive(Serialize, Deserialize)]
821pub struct ChangePassword {
822 pub new_password: String,
823}
824
825/// `PUT {AUTH_MODE}`.
826#[derive(Serialize, Deserialize)]
827pub struct SetAuthMode {
828 pub mode: AuthMode,
829}
830
831/// A WebAuthn challenge on its way to the browser.
832///
833/// `options` is the raw JSON the browser's `parseCreationOptionsFromJSON` /
834/// `parseRequestOptionsFromJSON` expects, carried as a string rather than a
835/// nested object. Neither side has to re-parse it: the server serializes the
836/// `webauthn-rs` type straight into it, and the client hands it to
837/// `JSON.parse` in the browser shim.
838#[derive(Serialize, Deserialize)]
839pub struct PasskeyChallenge {
840 /// Opaque handle for the server-side ceremony state. Echoed back on
841 /// finish. Not a credential, and useless on its own.
842 pub state_id: String,
843 pub options: String,
844}
845
846/// `POST {AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
847#[derive(Serialize, Deserialize)]
848pub struct PasskeyRegisterFinish {
849 pub state_id: String,
850 /// Label for the new passkey.
851 pub name: String,
852 /// The browser's `PublicKeyCredential.toJSON()` output, verbatim.
853 pub credential: String,
854}
855
856/// `POST {AUTH_PASSKEY_LOGIN}` — begin a passkey sign-in.
857#[derive(Serialize, Deserialize)]
858pub struct PasskeyLoginBegin {
859 /// Account name, when the user typed one. Without it the server issues a
860 /// discoverable challenge, which only finds passkeys the authenticator
861 /// stores itself.
862 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub name: Option<String>,
864 /// Ask for a conditional-mediation (autofill) challenge instead of a
865 /// modal one.
866 #[serde(default)]
867 pub conditional: bool,
868}
869
870/// `POST {AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
871#[derive(Serialize, Deserialize)]
872pub struct PasskeyLoginFinish {
873 pub state_id: String,
874 pub credential: String,
875}
876
877/// `POST {AUTH_LOGIN}` — the password leg of a sign-in.
878#[derive(Serialize, Deserialize)]
879pub struct LoginReq {
880 /// Omitted only when `state_id` names a half-finished sign-in, which
881 /// already knows who the user is.
882 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub name: Option<String>,
884 pub password: String,
885 /// Handle from a passkey leg that still needs a password (an
886 /// [`AuthMode::Both`] account signing in passkey-first).
887 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub state_id: Option<String>,
889}
890
891/// The answer to either sign-in leg.
892///
893/// Exactly one of the three shapes: signed in, needs a passkey next, or needs
894/// a password next. The two "needs" cases are how [`AuthMode::Both`] works,
895/// and which one appears depends only on which leg the user started with.
896#[derive(Serialize, Deserialize, Default)]
897pub struct LoginResp {
898 /// True when the session cookie is set and the user is in.
899 pub ok: bool,
900 /// Present when this leg passed but a passkey is still required.
901 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub passkey_challenge: Option<PasskeyChallenge>,
903 /// Present when this leg passed but the password is still required.
904 /// Carries the account name, so the form can show whose password it
905 /// wants, and the handle to send back with it.
906 #[serde(default, skip_serializing_if = "Option::is_none")]
907 pub password_required: Option<PasswordStep>,
908}
909
910#[derive(Serialize, Deserialize, Clone)]
911pub struct PasswordStep {
912 pub name: String,
913 pub state_id: String,
914}
915