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