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