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