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/// Admin view of every public calendar and address book feed:
75/// `{ADMIN_PIM_LINKS}` and `{ADMIN_PIM_LINKS}/{id}`.
76pub const ADMIN_PIM_LINKS: &str = "/api/admin/pim-links";
77/// The signed-in user's calendars and address books, own and lent to them
78/// (`GET`), and a new one (`POST`). `PUT` and `DELETE` on `{PIM_COLLECTIONS}/{id}`
79/// change or delete an own one; `DELETE` on a lent one ends the loan.
80/// `{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` lists (`GET`) and lends (`POST`)
81/// an own one; `DELETE` on `.../{user_id}` below it ends a loan.
82pub const PIM_COLLECTIONS: &str = "/api/pim/collections";
83pub const SHARES_SUFFIX: &str = "/shares";
84/// `{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`: the public feeds of an own
85/// collection (`GET`, `POST`); `DELETE` on `.../{link_id}` below it.
86pub const LINKS_SUFFIX: &str = "/links";
87/// `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}`: an `.ics` or `.vcf` body, or
88/// a JSON [`PimRootFile`] naming a file in a root.
89pub const IMPORT_SUFFIX: &str = "/import";
90/// `{PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}`: `GET` downloads the collection as
91/// one file; `POST` with a [`PimRootFile`] saves it into a root.
92pub const EXPORT_SUFFIX: &str = "/export";
93/// The system address book, which has no collection id: `GET` downloads it,
94/// `POST` with a [`PimRootFile`] saves it into a root.
95pub const PIM_SYSTEM_EXPORT: &str = "/api/pim/system/export";
96/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`: one event or contact
97/// as [`PimObjectDetail`]. `{...}/{name}{PHOTO_SUFFIX}`: a contact's photo as
98/// a WebP thumbnail.
99pub const OBJECTS_SUFFIX: &str = "/objects";
100pub const PHOTO_SUFFIX: &str = "/photo";
101/// `GET`: the instances of the readable calendars in a time range, as
102/// [`PimInstances`]. Params `from`, `to` (RFC 3339), `tz`, `collections`.
103pub const PIM_INSTANCES: &str = "/api/pim/instances";
104/// `GET`: the contacts of the readable address books, as [`PimContact`]s.
105/// Params `q`, `collections`.
106pub const PIM_CONTACTS: &str = "/api/pim/contacts";
107/// `GET`: the invitations the signed-in user has not answered, as
108/// [`PimInvitation`]s. `POST` a [`PimReply`] to answer one.
109pub const PIM_INVITATIONS: &str = "/api/pim/invitations";
110/// `GET`: what an `.ics` or `.vcf` file in a root holds, as [`PimPreview`].
111/// Params `root`, `path`, `tz`.
112pub const PIM_PREVIEW: &str = "/api/pim/preview";
113/// Public feed of one calendar or address book: `{FEED}/{token}.ics` or
114/// `.vcf`. The extension is optional.
115pub const FEED: &str = "/feed";
116/// Pseudo root id every signed-in admin has on the files API: the whole
117/// server root, read-only (the admin folder picker browses it). Real root
118/// ids are positive database ids. Not listed in `/me`.
119pub const ADMIN_ROOT: i64 = -1;
120
121// ---------------------------------------------------------------------------
122// Query params
123// ---------------------------------------------------------------------------
124
125/// `?action=...` on file URLs; without it the route lists the directory.
126pub const P_ACTION: &str = "action";
127pub const ACTION_DOWNLOAD: &str = "download";
128pub const ACTION_PREVIEW: &str = "preview";
129pub const ACTION_CONTENT: &str = "content";
130pub const ACTION_THUMB: &str = "thumb";
131/// `POST {FILES}/...?action=mkdir` — create a folder. Explicit, because the
132/// POST route also carries uploads and mutations.
133pub const ACTION_MKDIR: &str = "mkdir";
134/// `POST {FILES}/...?action=create-file` — create an empty file. Explicit
135/// like `mkdir`, for the same reason.
136pub const ACTION_CREATE_FILE: &str = "create-file";
137/// `POST {FILES}/...?action=exists` with an [`ExistsReq`] body — read-only
138/// pre-check for an upload: which of the given targets already exist.
139pub const ACTION_EXISTS: &str = "exists";
140/// `?format=...` for folder downloads (values: see `server::archive::ArchiveFormat`).
141pub const P_FORMAT: &str = "format";
142/// `?share=<token>` — authenticate file calls with a public share token.
143pub const P_SHARE: &str = "share";
144/// Search query text (`GET {SEARCH}`).
145pub const P_Q: &str = "q";
146/// Which index to search: `name`, `content` or `both`.
147pub const P_SCOPE: &str = "scope";
148/// Root id to search; omitted = the caller's first root.
149pub const P_ROOT: &str = "root";
150/// Folder inside the root to start a search in (relative to the root);
151/// omitted or empty = the whole root.
152pub const P_PATH: &str = "path";
153/// `?overwrite=true|1` on mutations and uploads.
154pub const P_OVERWRITE: &str = "overwrite";
155/// Listing order ([`SortKey`]); omitted = by name.
156pub const P_SORT: &str = "sort";
157/// `?desc=true` reverses the listing order. Folders still come first.
158pub const P_DESC: &str = "desc";
159/// First listing entry to return, in the sorted order.
160pub const P_OFFSET: &str = "offset";
161/// Listing entries to return, capped at [`MAX_LIST_ENTRIES`]; omitted = the cap.
162pub const P_LIMIT: &str = "limit";
163/// `?dirs=true` lists only the subfolders (the folder picker).
164pub const P_DIRS: &str = "dirs";
165/// `?around=name` returns the page that holds this entry, instead of the
166/// one at the offset. A missing name falls back to the offset.
167pub const P_AROUND: &str = "around";
168/// [`PIM_INSTANCES`]: the range, RFC 3339.
169pub const P_FROM: &str = "from";
170pub const P_TO: &str = "to";
171/// [`PIM_INSTANCES`], [`PIM_PREVIEW`], object detail: the IANA zone that
172/// all-day and floating times are read in. Default UTC.
173pub const P_TZ: &str = "tz";
174/// [`PIM_INSTANCES`], [`PIM_CONTACTS`]: comma-separated collection ids.
175/// Default all readable ones.
176pub const P_COLLECTIONS: &str = "collections";
177/// Object detail: the instance of a series, as in [`PimInstance`].
178pub const P_RECURRENCE_ID: &str = "recurrence_id";
179
180// ---------------------------------------------------------------------------
181// Wire enums
182// ---------------------------------------------------------------------------
183
184/// Access mode of a root or a share.
185///
186/// The serde names are also the values stored in the SQLite `mode` columns,
187/// so renaming a variant would break existing databases. The round-trip test
188/// below pins them.
189#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
190#[serde(rename_all = "lowercase")]
191pub enum Mode {
192 Rw,
193 Ro,
194}
195
196impl Mode {
197 /// The only question callers ask: may this root be written to?
198 pub fn is_writable(self) -> bool {
199 matches!(self, Mode::Rw)
200 }
201
202 /// The wire/database spelling, for `<select>` values and SQL params.
203 pub fn as_str(self) -> &'static str {
204 match self {
205 Mode::Rw => "rw",
206 Mode::Ro => "ro",
207 }
208 }
209
210 /// Parse the wire spelling. `None` for anything else.
211 pub fn from_wire(s: &str) -> Option<Self> {
212 match s {
213 "rw" => Some(Mode::Rw),
214 "ro" => Some(Mode::Ro),
215 _ => None,
216 }
217 }
218}
219
220/// Which mutation [`Mutation`] asks for.
221#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
222#[serde(rename_all = "lowercase")]
223pub enum Op {
224 Rename,
225 Move,
226 Copy,
227}
228
229/// What a listing is ordered by ([`P_SORT`]). Folders always sort before
230/// files, so a size or date order does not scatter them through the listing.
231#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
232#[serde(rename_all = "lowercase")]
233pub enum SortKey {
234 #[default]
235 Name,
236 Size,
237 Modified,
238}
239
240impl SortKey {
241 pub fn as_str(self) -> &'static str {
242 match self {
243 SortKey::Name => "name",
244 SortKey::Size => "size",
245 SortKey::Modified => "modified",
246 }
247 }
248
249 pub fn parse(s: &str) -> Option<Self> {
250 match s {
251 "name" => Some(SortKey::Name),
252 "size" => Some(SortKey::Size),
253 "modified" => Some(SortKey::Modified),
254 _ => None,
255 }
256 }
257}
258
259// ---------------------------------------------------------------------------
260// Request bodies (client → server)
261// ---------------------------------------------------------------------------
262
263#[derive(Serialize, Deserialize)]
264pub struct Credentials {
265 pub name: String,
266 pub password: String,
267}
268
269/// Rename / move / copy (one body for all file mutations).
270#[derive(Serialize, Deserialize)]
271pub struct Mutation {
272 pub op: Op,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub new_name: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub dst_root_id: Option<i64>,
277 /// Destination directory, relative to `dst_root_id`.
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub dst: Option<String>,
280 #[serde(default)]
281 pub overwrite: bool,
282}
283
284/// A user folder: path relative to the server root + access mode.
285#[derive(Serialize, Deserialize)]
286pub struct Root {
287 /// Path relative to the server root; "." means the whole root.
288 pub path: String,
289 #[serde(default = "default_rw")]
290 pub mode: Mode,
291}
292
293fn default_rw() -> Mode {
294 Mode::Rw
295}
296
297#[derive(Serialize, Deserialize)]
298pub struct CreateUser {
299 pub name: String,
300 pub password: String,
301 #[serde(default)]
302 pub is_admin: bool,
303 #[serde(default)]
304 pub roots: Vec<Root>,
305}
306
307#[derive(Serialize, Deserialize)]
308pub struct UpdateUser {
309 /// Setting one is also the recovery path for a locked-out account: it
310 /// deletes every passkey and puts the account back on
311 /// [`AuthMode::Either`], leaving the new password as the one way in.
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub password: Option<String>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub is_admin: Option<bool>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub active: Option<bool>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub roots: Option<Vec<Root>>,
320}
321
322/// Server settings (GET/PUT `{ADMIN_SETTINGS}`).
323#[derive(Serialize, Deserialize, Clone)]
324pub struct Settings {
325 pub allow_writable_shares: bool,
326 /// Folders left out of every search, as paths relative to the server
327 /// root. A path covers everything beneath it.
328 #[serde(default)]
329 pub search_excludes: Vec<String>,
330}
331
332#[derive(Serialize, Deserialize)]
333pub struct CreateShare {
334 pub root_id: i64,
335 /// Item path relative to the root ("" or "." for the root itself).
336 pub path: String,
337 #[serde(default)]
338 pub writable: bool,
339 /// Absolute expiry as RFC 3339; absent = never.
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub expires_at: Option<String>,
342 /// Password the visitor must enter before the share opens; absent = none.
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub password: Option<String>,
345}
346
347/// POST `{SHARE}/{token}/unlock` — the password for a protected share.
348#[derive(Serialize, Deserialize)]
349pub struct UnlockShare {
350 pub password: String,
351}
352
353// ---------------------------------------------------------------------------
354// Responses (server → client)
355// ---------------------------------------------------------------------------
356
357/// What a listing entry actually is, decided by the server from the file's
358/// leading bytes (magic numbers via `infer`, plus a text/binary heuristic) —
359/// not from its name. Drives the icon and the preview the client offers.
360///
361/// Deliberately coarse: this answers "which viewer opens this", not "what
362/// exact format is it". Syntax highlighting still keys off the extension,
363/// because `.h` is C or C++ and no amount of sniffing decides that.
364#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
365#[serde(rename_all = "lowercase")]
366pub enum FileKind {
367 Dir,
368 Image,
369 Video,
370 Audio,
371 Pdf,
372 Archive,
373 /// Anything that decodes as text: source code, markup, config, plain text.
374 Text,
375 /// Recognized-but-not-viewable, or undecodable bytes.
376 Binary,
377}
378
379#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
380pub struct Entry {
381 pub name: String,
382 pub is_dir: bool,
383 pub size: u64,
384 /// RFC 3339 UTC modification time.
385 pub mtime: String,
386 /// Content-sniffed kind (see [`FileKind`]).
387 pub kind: FileKind,
388}
389
390/// One page of a folder listing.
391#[derive(Serialize, Deserialize)]
392pub struct FilesResp {
393 pub entries: Vec<Entry>,
394 /// Entries in the whole folder, across all pages.
395 pub total: usize,
396 /// Position of `entries[0]` in the sorted folder. An offset past the end
397 /// comes back as the start of the last page.
398 pub offset: usize,
399}
400
401/// Cap on entries in one listing page.
402pub const MAX_LIST_ENTRIES: usize = 10_000;
403
404/// One streamed search result. Each is sent as one SSE event
405/// (`data: <json>`), in the order found; the `done` event always ends the
406/// stream.
407#[derive(Serialize, Deserialize, Clone)]
408#[serde(tag = "type", rename_all = "snake_case")]
409pub enum SearchEvent {
410 /// A file or folder whose name matched (scope `name`/`both`).
411 File {
412 root_id: i64,
413 /// Path relative to the root, `/`-separated.
414 path: String,
415 size: u64,
416 is_dir: bool,
417 /// Sniffed the same way as a directory listing's, so the client can
418 /// pick an icon and a viewer without a second guess at the name.
419 kind: FileKind,
420 },
421 /// One matching line (scope `content`/`both`). `path` is relative to the
422 /// root; `text` is the matched line, truncated to a fixed length.
423 Match {
424 root_id: i64,
425 path: String,
426 line: u64,
427 text: String,
428 },
429 /// Stream finished. `stopped` is true when the client aborted before the
430 /// search completed.
431 Done {
432 stopped: bool,
433 /// Files examined (walked) before the stream ended.
434 scanned: usize,
435 /// Files skipped for content search (over the size cap).
436 skipped: usize,
437 elapsed_ms: u64,
438 },
439}
440
441#[derive(Serialize, Deserialize, Clone, PartialEq)]
442pub struct UserInfo {
443 pub id: i64,
444 pub name: String,
445 pub is_admin: bool,
446 /// Profile setting: single click opens entries (off = click selects,
447 /// double click opens).
448 pub single_click_open: bool,
449 /// Profile setting: show image and video thumbnails in the grid.
450 pub thumbnails: bool,
451 /// Preferred UI language tag ("en", "de", "fr"); None = follow the
452 /// browser.
453 pub language: Option<String>,
454 /// Profile setting: the root the UI opens on page load and on the home
455 /// link. Always one of `Me::roots` (the server drops a stale id), or
456 /// None for the root picker.
457 pub default_root_id: Option<i64>,
458 /// What this account needs to sign in.
459 pub auth_mode: AuthMode,
460 /// Whether a password is set at all. False means passkeys only.
461 pub has_password: bool,
462}
463
464#[derive(Serialize, Deserialize, Clone)]
465pub struct RootInfo {
466 pub id: i64,
467 pub name: String,
468 pub path: String,
469 pub mode: Mode,
470}
471
472/// GET `{AUTH_ME}`.
473#[derive(Serialize, Deserialize, Clone)]
474pub struct Me {
475 /// True while no users exist yet (first-boot setup).
476 pub first_boot: bool,
477 /// None on first boot.
478 pub user: Option<UserInfo>,
479 pub roots: Vec<RootInfo>,
480 pub allow_writable_shares: bool,
481 /// Whether the server can make thumbnails at all (`--cache` is set).
482 /// The profile setting is only offered when this is true.
483 pub thumbnails_available: bool,
484 /// `--public-url`, if set. The UI builds share links from it instead of
485 /// the page origin.
486 pub public_url: Option<String>,
487}
488
489/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
490#[derive(Serialize, Deserialize, Clone)]
491pub struct ShareInfo {
492 /// Also the share's synthetic root id in file API calls.
493 pub id: i64,
494 pub token: String,
495 /// Display name (file/folder name, or the root's name for ".").
496 pub name: String,
497 pub is_file: bool,
498 pub writable: bool,
499 /// Path relative to the server root.
500 pub target: String,
501 /// RFC 3339 UTC creation time.
502 pub created_at: String,
503 /// RFC 3339 UTC expiry; None = never.
504 pub expires_at: Option<String>,
505 /// The file's kind for file shares (None for folder shares, and when
506 /// not sniffed — the public resolve endpoint fills it in).
507 pub kind: Option<FileKind>,
508 /// Whether the share asks for a password. Never the password itself.
509 pub has_password: bool,
510}
511
512/// One share plus who owns it: GET `{ADMIN_SHARES}`.
513///
514/// Admin-only: it carries the full [`ShareInfo::token`], and a token is access.
515/// Kept separate from [`ShareInfo`] because the public resolve route answers
516/// with a `ShareInfo` to anonymous visitors.
517#[derive(Serialize, Deserialize, Clone)]
518pub struct AdminShare {
519 #[serde(flatten)]
520 pub share: ShareInfo,
521 pub creator_id: i64,
522 pub creator_name: String,
523 /// Whether the creator's account can still sign in. Deactivating an account
524 /// does not revoke its shares, so `false` marks a live link its owner can no
525 /// longer manage.
526 pub creator_active: bool,
527}
528
529/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
530#[derive(Serialize, Deserialize, Clone)]
531pub struct AdminUser {
532 pub id: i64,
533 pub name: String,
534 pub is_admin: bool,
535 pub active: bool,
536 pub roots: Vec<RootInfo>,
537}
538
539/// Acknowledges a successful mutation. Carries nothing: the 2xx status is
540/// the acknowledgement, so the body is the empty object.
541#[derive(Serialize, Deserialize)]
542pub struct OkResp {}
543
544#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
545#[serde(rename_all = "lowercase")]
546pub enum PimCollectionKind {
547 Calendar,
548 Addressbook,
549}
550
551/// How a calendar or address book is lent. The serde names are also the
552/// values stored in `pim_shares.mode`.
553#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
554pub enum PimShareMode {
555 #[serde(rename = "ro")]
556 Ro,
557 /// Change members, but send no scheduling messages as the owner.
558 #[serde(rename = "rw")]
559 Rw,
560 /// Also invite and answer as the owner, named in SENT-BY.
561 #[serde(rename = "rw+schedule")]
562 RwSchedule,
563}
564
565impl PimShareMode {
566 pub fn as_str(self) -> &'static str {
567 match self {
568 PimShareMode::Ro => "ro",
569 PimShareMode::Rw => "rw",
570 PimShareMode::RwSchedule => "rw+schedule",
571 }
572 }
573
574 pub fn from_wire(s: &str) -> Option<Self> {
575 match s {
576 "ro" => Some(PimShareMode::Ro),
577 "rw" => Some(PimShareMode::Rw),
578 "rw+schedule" => Some(PimShareMode::RwSchedule),
579 _ => None,
580 }
581 }
582}
583
584/// One entry of `GET {PIM_COLLECTIONS}`.
585#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
586pub struct PimCollectionInfo {
587 /// `0` for the system address book and `-1` for the birthday calendar,
588 /// which the server generates.
589 pub id: i64,
590 pub kind: PimCollectionKind,
591 pub name: String,
592 /// The CalDAV or CardDAV URL, as seen by the signed-in user.
593 pub url: String,
594 pub owner: String,
595 /// `None` for an own collection, the loan's mode for a lent one.
596 pub mode: Option<PimShareMode>,
597 /// Generated by the server, so read-only.
598 #[serde(default)]
599 pub generated: bool,
600 #[serde(default)]
601 pub color: Option<String>,
602 #[serde(default)]
603 pub description: Option<String>,
604 /// Calendars: the component types it takes, e.g. `VEVENT`.
605 #[serde(default)]
606 pub components: Vec<String>,
607 /// Calendars: adds no busy time to scheduling.
608 #[serde(default)]
609 pub transparent: bool,
610 /// The calendar that receives invitations. It cannot be deleted.
611 #[serde(default)]
612 pub is_default: bool,
613 /// Own collections: how many accounts it is lent to.
614 #[serde(default)]
615 pub shares: usize,
616 /// Own collections: how many public feeds it has.
617 #[serde(default)]
618 pub links: usize,
619}
620
621/// `POST {PIM_COLLECTIONS}`.
622#[derive(Serialize, Deserialize, Clone, Debug)]
623pub struct CreatePimCollection {
624 pub kind: PimCollectionKind,
625 pub name: String,
626 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub color: Option<String>,
628 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub description: Option<String>,
630 /// Calendars: `VEVENT`, `VTODO`, `VJOURNAL`. Empty takes all three.
631 #[serde(default, skip_serializing_if = "Vec::is_empty")]
632 pub components: Vec<String>,
633}
634
635/// `PUT {PIM_COLLECTIONS}/{id}`: absent fields stay; an empty `color` or
636/// `description` removes it.
637#[derive(Serialize, Deserialize, Clone, Debug, Default)]
638pub struct UpdatePimCollection {
639 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub name: Option<String>,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub color: Option<String>,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub description: Option<String>,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
646 pub transparent: Option<bool>,
647}
648
649/// One occurrence of an event, task or journal entry: `GET {PIM_INSTANCES}`.
650#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
651pub struct PimInstance {
652 pub collection_id: i64,
653 /// The object's resource name in the collection.
654 pub name: String,
655 pub uid: String,
656 /// The original start of a recurring instance (RFC 3339 UTC); `None`
657 /// for an object that does not recur.
658 pub recurrence_id: Option<String>,
659 /// RFC 3339 UTC. An all-day instance starts at midnight in `tz`.
660 pub start: String,
661 pub end: String,
662 pub all_day: bool,
663 /// `VEVENT`, `VTODO` or `VJOURNAL`.
664 pub component: String,
665 pub summary: Option<String>,
666 pub location: Option<String>,
667 /// `TENTATIVE`, `CONFIRMED`, `CANCELLED`, ...
668 pub status: Option<String>,
669 pub transparent: bool,
670 pub has_attendees: bool,
671 /// The calendar owner's PARTSTAT when they are an attendee.
672 pub partstat: Option<String>,
673 /// The organizer's name, else address.
674 pub organizer: Option<String>,
675}
676
677#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
678pub struct PimInstances {
679 pub instances: Vec<PimInstance>,
680 /// Not every instance fits: the range held too many.
681 pub truncated: bool,
682}
683
684#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
685pub struct PimPerson {
686 pub name: Option<String>,
687 /// The calendar user address, e.g. `mailto:...`.
688 pub address: String,
689}
690
691#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
692pub struct PimAttendee {
693 #[serde(flatten)]
694 pub person: PimPerson,
695 pub partstat: Option<String>,
696 pub role: Option<String>,
697 /// The calendar owner.
698 pub is_owner: bool,
699}
700
701#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
702pub struct PimEventDetail {
703 pub collection_id: i64,
704 pub name: String,
705 pub uid: String,
706 pub component: String,
707 pub summary: Option<String>,
708 pub description: Option<String>,
709 pub location: Option<String>,
710 pub url: Option<String>,
711 pub status: Option<String>,
712 pub transparent: bool,
713 pub all_day: bool,
714 pub categories: Vec<String>,
715 /// The RRULE of the series, e.g. `FREQ=WEEKLY;BYDAY=MO`.
716 pub rrule: Option<String>,
717 pub organizer: Option<PimPerson>,
718 pub attendees: Vec<PimAttendee>,
719 /// The signed-in user may change the object with a client.
720 pub can_edit: bool,
721 /// The calendar owner is an attendee and the signed-in user may answer
722 /// for them.
723 pub can_reply: bool,
724}
725
726#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
727pub struct PimLabeled {
728 /// `work`, `home`, a client's own label, ...
729 pub label: Option<String>,
730 pub value: String,
731}
732
733/// One entry of `GET {PIM_CONTACTS}`.
734#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
735pub struct PimContact {
736 pub collection_id: i64,
737 pub name: String,
738 pub full_name: String,
739 pub org: Option<String>,
740 pub email: Option<String>,
741 pub phone: Option<String>,
742 pub has_photo: bool,
743 pub is_group: bool,
744}
745
746#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
747pub struct PimContactDetail {
748 pub collection_id: i64,
749 pub name: String,
750 pub uid: Option<String>,
751 pub full_name: String,
752 pub org: Option<String>,
753 pub title: Option<String>,
754 pub emails: Vec<PimLabeled>,
755 pub phones: Vec<PimLabeled>,
756 /// One address per entry, its parts on separate lines.
757 pub addresses: Vec<PimLabeled>,
758 pub urls: Vec<PimLabeled>,
759 /// `1980-03-15`, or `--03-15` without a year.
760 pub birthday: Option<String>,
761 pub anniversary: Option<String>,
762 pub note: Option<String>,
763 pub is_group: bool,
764 /// A group's members, by name where the address book knows them.
765 pub members: Vec<String>,
766 /// `{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}`.
767 pub photo_url: Option<String>,
768 pub can_edit: bool,
769}
770
771/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`.
772#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
773#[serde(tag = "type", rename_all = "lowercase")]
774pub enum PimObjectDetail {
775 Event(PimEventDetail),
776 Contact(PimContactDetail),
777}
778
779/// One entry of `GET {PIM_INVITATIONS}`: a series, or one instance of it
780/// when that instance was invited on its own.
781#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
782pub struct PimInvitation {
783 pub collection_id: i64,
784 pub name: String,
785 pub uid: String,
786 /// `None`: the whole series.
787 pub recurrence_id: Option<String>,
788 pub summary: Option<String>,
789 pub location: Option<String>,
790 pub organizer: Option<PimPerson>,
791 /// The next start, RFC 3339 UTC.
792 pub start: String,
793 pub end: String,
794 pub all_day: bool,
795 pub recurring: bool,
796}
797
798/// `POST {PIM_INVITATIONS}`: the calendar owner's answer. The server writes
799/// it into their copy and tells the organizer, as a client would.
800#[derive(Serialize, Deserialize, Clone, Debug)]
801pub struct PimReply {
802 pub collection_id: i64,
803 pub name: String,
804 /// Answer one instance only. As in [`PimInstance::recurrence_id`].
805 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub recurrence_id: Option<String>,
807 /// `ACCEPTED`, `TENTATIVE` or `DECLINED`.
808 pub partstat: String,
809 /// The IANA zone of the request that listed the instance.
810 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub tz: Option<String>,
812}
813
814#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
815pub struct PimPreviewEvent {
816 pub summary: Option<String>,
817 /// RFC 3339 UTC.
818 pub start: Option<String>,
819 pub all_day: bool,
820 pub recurring: bool,
821}
822
823#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
824pub struct PimPreviewContact {
825 pub full_name: String,
826 pub email: Option<String>,
827 pub phone: Option<String>,
828}
829
830/// `GET {PIM_PREVIEW}`.
831#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
832pub struct PimPreview {
833 pub kind: PimCollectionKind,
834 /// All objects in the file, also those beyond the lists.
835 pub total: usize,
836 /// Objects that are not valid calendar or address data.
837 pub invalid: usize,
838 pub events: Vec<PimPreviewEvent>,
839 pub contacts: Vec<PimPreviewContact>,
840}
841
842/// `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`.
843#[derive(Serialize, Deserialize, Clone, Debug)]
844pub struct PimShareInfo {
845 pub user_id: i64,
846 pub user_name: String,
847 pub mode: PimShareMode,
848}
849
850/// `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}`: lend to an account, or
851/// change the mode of an existing loan.
852#[derive(Serialize, Deserialize)]
853pub struct CreatePimShare {
854 pub user: String,
855 pub mode: PimShareMode,
856}
857
858/// One entry of `GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
859#[derive(Serialize, Deserialize, Clone, Debug)]
860pub struct PimLinkInfo {
861 pub id: i64,
862 /// `{FEED}/{token}` with the extension.
863 pub path: String,
864 pub busy_only: bool,
865 pub created_at: String,
866 pub expires_at: Option<String>,
867 pub has_password: bool,
868}
869
870/// One feed with its collection and owner: GET `{ADMIN_PIM_LINKS}`.
871#[derive(Serialize, Deserialize, Clone, Debug)]
872pub struct AdminPimLink {
873 #[serde(flatten)]
874 pub link: PimLinkInfo,
875 pub collection_id: i64,
876 pub collection_name: String,
877 pub kind: PimCollectionKind,
878 pub owner_id: i64,
879 pub owner_name: String,
880 /// Whether the owner can still sign in. A disabled owner's feeds stay live.
881 pub owner_active: bool,
882}
883
884/// `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}`.
885#[derive(Serialize, Deserialize, Default)]
886pub struct CreatePimLink {
887 /// Calendars only: events without their details.
888 #[serde(default)]
889 pub busy_only: bool,
890 /// Absolute expiry as RFC 3339; absent = never.
891 #[serde(default, skip_serializing_if = "Option::is_none")]
892 pub expires_at: Option<String>,
893 /// Asked for with HTTP Basic; the user name is ignored.
894 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub password: Option<String>,
896}
897
898/// A file in one of the signed-in user's roots.
899#[derive(Serialize, Deserialize)]
900pub struct PimRootFile {
901 pub root_id: i64,
902 pub path: String,
903}
904
905/// The answer to an import.
906#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
907pub struct PimImportResult {
908 pub created: usize,
909 pub updated: usize,
910 /// All skipped objects, also those beyond `skipped`.
911 pub skipped_total: usize,
912 /// The first skipped objects.
913 pub skipped: Vec<PimSkipped>,
914}
915
916#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
917pub struct PimSkipped {
918 pub uid: Option<String>,
919 /// The precondition a PUT of the object would fail, e.g.
920 /// `valid-calendar-object-resource`.
921 pub reason: String,
922}
923
924#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
925#[serde(rename_all = "lowercase")]
926pub enum RoomKind {
927 Room,
928 Resource,
929}
930
931/// A room or resource: `GET {ADMIN_ROOMS}`.
932#[derive(Serialize, Deserialize, Clone, Debug)]
933pub struct RoomInfo {
934 pub id: i64,
935 /// The URL segment. Fixed, since it is also the scheduling address.
936 pub name: String,
937 pub display_name: String,
938 pub kind: RoomKind,
939 /// The principal URL.
940 pub url: String,
941}
942
943/// `POST {ADMIN_ROOMS}`.
944#[derive(Serialize, Deserialize)]
945pub struct CreateRoom {
946 pub name: String,
947 /// Defaults to `name`.
948 pub display_name: Option<String>,
949 pub kind: RoomKind,
950}
951
952/// `PUT {ADMIN_ROOMS}/{id}`.
953#[derive(Serialize, Deserialize)]
954pub struct UpdateRoom {
955 pub display_name: String,
956}
957
958/// `POST ...?action=exists` body: upload targets relative to the request
959/// directory (may contain subfolders, like upload part names).
960#[derive(Serialize, Deserialize)]
961pub struct ExistsReq {
962 pub paths: Vec<String>,
963}
964
965/// One existing upload target.
966#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
967pub struct Existing {
968 pub path: String,
969 pub is_dir: bool,
970}
971
972/// `POST ...?action=exists` response: the subset of the requested paths
973/// that exist, in request order.
974#[derive(Serialize, Deserialize)]
975pub struct ExistsResp {
976 pub existing: Vec<Existing>,
977}
978
979/// PUT `?action=content` (editor save): the file's new mtime (unix seconds).
980#[derive(Serialize, Deserialize)]
981pub struct SaveResp {
982 pub mtime: i64,
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 /// The serde spellings are the database values too, so they are pinned.
990 #[test]
991 fn mode_wire_format_is_rw_ro() {
992 assert_eq!(serde_json::to_string(&Mode::Rw).unwrap(), "\"rw\"");
993 assert_eq!(serde_json::to_string(&Mode::Ro).unwrap(), "\"ro\"");
994 for m in [Mode::Rw, Mode::Ro] {
995 let s = serde_json::to_string(&m).unwrap();
996 assert_eq!(serde_json::from_str::<Mode>(&s).unwrap(), m);
997 // `as_str`/`from_wire` must agree with serde.
998 assert_eq!(s, format!("\"{}\"", m.as_str()));
999 assert_eq!(Mode::from_wire(m.as_str()), Some(m));
1000 }
1001 assert_eq!(Mode::from_wire("both"), None);
1002 assert!(serde_json::from_str::<Mode>("\"both\"").is_err());
1003 assert!(Mode::Rw.is_writable());
1004 assert!(!Mode::Ro.is_writable());
1005 }
1006
1007 #[test]
1008 fn pim_share_mode_wire_format() {
1009 for m in [PimShareMode::Ro, PimShareMode::Rw, PimShareMode::RwSchedule] {
1010 let s = serde_json::to_string(&m).unwrap();
1011 assert_eq!(s, format!("\"{}\"", m.as_str()));
1012 assert_eq!(serde_json::from_str::<PimShareMode>(&s).unwrap(), m);
1013 assert_eq!(PimShareMode::from_wire(m.as_str()), Some(m));
1014 }
1015 assert_eq!(PimShareMode::RwSchedule.as_str(), "rw+schedule");
1016 }
1017
1018 #[test]
1019 fn op_wire_format() {
1020 assert_eq!(serde_json::to_string(&Op::Rename).unwrap(), "\"rename\"");
1021 assert_eq!(serde_json::to_string(&Op::Move).unwrap(), "\"move\"");
1022 assert_eq!(serde_json::to_string(&Op::Copy).unwrap(), "\"copy\"");
1023 for op in [Op::Rename, Op::Move, Op::Copy] {
1024 let s = serde_json::to_string(&op).unwrap();
1025 assert_eq!(serde_json::from_str::<Op>(&s).unwrap(), op);
1026 }
1027 assert!(serde_json::from_str::<Op>("\"explode\"").is_err());
1028 }
1029
1030 #[test]
1031 fn mutation_round_trip_skips_absent_fields() {
1032 let m = Mutation {
1033 op: Op::Move,
1034 new_name: None,
1035 dst_root_id: Some(3),
1036 dst: Some("docs".into()),
1037 overwrite: true,
1038 };
1039 let s = serde_json::to_string(&m).unwrap();
1040 assert!(!s.contains("new_name"));
1041 let back: Mutation = serde_json::from_str(&s).unwrap();
1042 assert_eq!(back.dst_root_id, Some(3));
1043 assert_eq!(back.op, Op::Move);
1044 }
1045
1046 #[test]
1047 fn mutation_defaults_missing_fields() {
1048 let m: Mutation = serde_json::from_str(r#"{"op":"rename","new_name":"a.txt"}"#).unwrap();
1049 assert!(!m.overwrite);
1050 assert_eq!(m.dst, None);
1051 }
1052
1053 #[test]
1054 fn root_defaults_mode_to_rw() {
1055 let r: Root = serde_json::from_str(r#"{"path":"docs"}"#).unwrap();
1056 assert_eq!(r.mode, Mode::Rw);
1057 }
1058
1059 #[test]
1060 fn me_round_trip() {
1061 let me = Me {
1062 first_boot: false,
1063 user: Some(UserInfo {
1064 id: 1,
1065 name: "admin".into(),
1066 is_admin: true,
1067 single_click_open: false,
1068 thumbnails: true,
1069 language: None,
1070 default_root_id: None,
1071 auth_mode: AuthMode::Either,
1072 has_password: true,
1073 }),
1074 roots: vec![RootInfo {
1075 id: 1,
1076 name: "root".into(),
1077 path: ".".into(),
1078 mode: Mode::Rw,
1079 }],
1080 allow_writable_shares: false,
1081 thumbnails_available: true,
1082 public_url: None,
1083 };
1084 let s = serde_json::to_string(&me).unwrap();
1085 let back: Me = serde_json::from_str(&s).unwrap();
1086 assert_eq!(back.roots.len(), 1);
1087 }
1088}
1089
1090// ---------------------------------------------------------------------------
1091// Sign-in methods: password, passkeys, and how they combine
1092// ---------------------------------------------------------------------------
1093
1094/// What an account needs to sign in.
1095///
1096/// Not a "2FA on/off" flag: [`AuthMode::Either`] with no password is a
1097/// passkey-only account, which is still two factors when the authenticator
1098/// does user verification (the server always asks for it).
1099#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
1100#[serde(rename_all = "lowercase")]
1101pub enum AuthMode {
1102 /// Password *or* passkey. Either one alone signs the user in.
1103 #[default]
1104 Either,
1105 /// Password *and* passkey. Both legs must pass, in either order.
1106 Both,
1107}
1108
1109impl AuthMode {
1110 pub fn as_str(self) -> &'static str {
1111 match self {
1112 AuthMode::Either => "either",
1113 AuthMode::Both => "both",
1114 }
1115 }
1116
1117 pub fn from_wire(s: &str) -> Option<Self> {
1118 match s {
1119 "either" => Some(AuthMode::Either),
1120 "both" => Some(AuthMode::Both),
1121 _ => None,
1122 }
1123 }
1124}
1125
1126/// One registered passkey, as shown in profile settings. Never carries key
1127/// material.
1128#[derive(Serialize, Deserialize, Clone)]
1129pub struct PasskeyInfo {
1130 pub id: i64,
1131 /// User-chosen label ("YubiKey", "Work laptop").
1132 pub name: String,
1133 /// RFC 3339 UTC.
1134 pub created_at: String,
1135 pub last_used_at: Option<String>,
1136 /// Whether the browser reported this credential as discoverable, so it
1137 /// can sign in without the account name. `None` when the browser did not
1138 /// say — the `credProps` extension is optional and unsigned, so absence
1139 /// means "unknown", never "no".
1140 pub discoverable: Option<bool>,
1141}
1142
1143/// One app password, as shown in profile settings.
1144///
1145/// WebDAV-only: it never signs in to the web UI. Never carries the secret,
1146/// which exists only in [`NewAppPassword`].
1147#[derive(Serialize, Deserialize, Clone)]
1148pub struct AppPasswordInfo {
1149 pub id: i64,
1150 /// User-chosen label ("Laptop mount", "phone").
1151 pub name: String,
1152 /// RFC 3339 UTC.
1153 pub created_at: String,
1154 /// Only tracked to the hour.
1155 pub last_used_at: Option<String>,
1156}
1157
1158/// `POST {AUTH_APP_PASSWORDS}`.
1159#[derive(Serialize, Deserialize)]
1160pub struct CreateAppPassword {
1161 pub name: String,
1162}
1163
1164/// The answer to `POST {AUTH_APP_PASSWORDS}`.
1165///
1166/// The only time `secret` is readable. The server keeps a hash of it.
1167#[derive(Serialize, Deserialize)]
1168pub struct NewAppPassword {
1169 #[serde(flatten)]
1170 pub info: AppPasswordInfo,
1171 pub secret: String,
1172}
1173
1174/// `POST {AUTH_PASSWORD}` — set or change the password.
1175///
1176/// No current password to confirm: a passkey-only account has none to give.
1177/// The session is the gate, and the server drops the account's other
1178/// sessions on every change.
1179#[derive(Serialize, Deserialize)]
1180pub struct ChangePassword {
1181 pub new_password: String,
1182}
1183
1184/// `PUT {AUTH_MODE}`.
1185#[derive(Serialize, Deserialize)]
1186pub struct SetAuthMode {
1187 pub mode: AuthMode,
1188}
1189
1190/// A WebAuthn challenge on its way to the browser.
1191///
1192/// `options` is the raw JSON the browser's `parseCreationOptionsFromJSON` /
1193/// `parseRequestOptionsFromJSON` expects, carried as a string rather than a
1194/// nested object. Neither side has to re-parse it: the server serializes the
1195/// `webauthn-rs` type straight into it, and the client hands it to
1196/// `JSON.parse` in the browser shim.
1197#[derive(Serialize, Deserialize)]
1198pub struct PasskeyChallenge {
1199 /// Opaque handle for the server-side ceremony state. Echoed back on
1200 /// finish. Not a credential, and useless on its own.
1201 pub state_id: String,
1202 pub options: String,
1203}
1204
1205/// `POST {AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
1206#[derive(Serialize, Deserialize)]
1207pub struct PasskeyRegisterFinish {
1208 pub state_id: String,
1209 /// Label for the new passkey.
1210 pub name: String,
1211 /// The browser's `PublicKeyCredential.toJSON()` output, verbatim.
1212 pub credential: String,
1213}
1214
1215/// `POST {AUTH_PASSKEY_LOGIN}` — begin a passkey sign-in.
1216#[derive(Serialize, Deserialize)]
1217pub struct PasskeyLoginBegin {
1218 /// Account name, when the user typed one. Without it the server issues a
1219 /// discoverable challenge, which only finds passkeys the authenticator
1220 /// stores itself.
1221 #[serde(default, skip_serializing_if = "Option::is_none")]
1222 pub name: Option<String>,
1223 /// Ask for a conditional-mediation (autofill) challenge instead of a
1224 /// modal one.
1225 #[serde(default)]
1226 pub conditional: bool,
1227}
1228
1229/// `POST {AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
1230#[derive(Serialize, Deserialize)]
1231pub struct PasskeyLoginFinish {
1232 pub state_id: String,
1233 pub credential: String,
1234}
1235
1236/// `POST {AUTH_LOGIN}` — the password leg of a sign-in.
1237#[derive(Serialize, Deserialize)]
1238pub struct LoginReq {
1239 /// Omitted only when `state_id` names a half-finished sign-in, which
1240 /// already knows who the user is.
1241 #[serde(default, skip_serializing_if = "Option::is_none")]
1242 pub name: Option<String>,
1243 pub password: String,
1244 /// Handle from a passkey leg that still needs a password (an
1245 /// [`AuthMode::Both`] account signing in passkey-first).
1246 #[serde(default, skip_serializing_if = "Option::is_none")]
1247 pub state_id: Option<String>,
1248}
1249
1250/// The answer to either sign-in leg.
1251///
1252/// Exactly one of the three shapes: signed in, needs a passkey next, or needs
1253/// a password next. The two "needs" cases are how [`AuthMode::Both`] works,
1254/// and which one appears depends only on which leg the user started with.
1255#[derive(Serialize, Deserialize, Default)]
1256pub struct LoginResp {
1257 /// True when the session cookie is set and the user is in.
1258 pub ok: bool,
1259 /// Present when this leg passed but a passkey is still required.
1260 #[serde(default, skip_serializing_if = "Option::is_none")]
1261 pub passkey_challenge: Option<PasskeyChallenge>,
1262 /// Present when this leg passed but the password is still required.
1263 /// Carries the account name, so the form can show whose password it
1264 /// wants, and the handle to send back with it.
1265 #[serde(default, skip_serializing_if = "Option::is_none")]
1266 pub password_required: Option<PasswordStep>,
1267}
1268
1269#[derive(Serialize, Deserialize, Clone)]
1270pub struct PasswordStep {
1271 pub name: String,
1272 pub state_id: String,
1273}
1274