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