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