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