pim_api.rs
⎇
Raw
1//! JSON management of calendars and address books (session-authenticated):
2//! - `GET {PIM_COLLECTIONS}` — own and lent collections
3//! - `GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — loans of an own collection
4//! - `POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}` — lend it, or change a loan
5//! - `DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}` — end a loan
6//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}` — public feeds of an own collection
7//! - `DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}` — revoke a feed
8//! - `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}` — import a file
9//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download, or save into a root
10//!
11//! Public: `GET {FEED}/{token}` — a collection as one file.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use api_types::{
17 CreatePimLink, CreatePimShare, FEED, OkResp, PimCollectionInfo, PimCollectionKind,
18 PimImportResult, PimLinkInfo, PimRootFile, PimShareInfo, PimShareMode, PimSkipped,
19};
20use axum::Json;
21use axum::body::Body;
22use axum::extract::{Path as AxumPath, State};
23use axum::http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
24use axum::http::{HeaderMap, StatusCode};
25use axum::response::{IntoResponse, Response};
26use pimdav::bundle::{self, Detail};
27use pimdav::object;
28use sha2::{Digest, Sha256};
29
30use crate::api::common::{SessionUser, blocking, hash_password, validate_password};
31use crate::api::dav::challenge;
32use crate::api::files::{disposition, find_root, require_rw_root};
33use crate::api::pim::{INBOX, collection_href, etag_of};
34use crate::api::pim_schedule::{self, Directory, object_name};
35use crate::auth;
36use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp};
37use crate::error::{ApiError, AppState};
38use crate::fs;
39
40/// The largest file an import reads.
41const MAX_IMPORT: usize = 20 * 1024 * 1024;
42
43/// How many skipped objects an import names.
44const MAX_SKIPPED: usize = 100;
45
46fn wire_kind(kind: PimKind) -> PimCollectionKind {
47 match kind {
48 PimKind::Calendar => PimCollectionKind::Calendar,
49 PimKind::AddressBook => PimCollectionKind::Addressbook,
50 }
51}
52
53fn name_of(c: &PimCollection) -> String {
54 c.displayname.clone().unwrap_or_else(|| c.slug.clone())
55}
56
57/// GET {PIM_COLLECTIONS}
58pub async fn list(
59 State(state): State<Arc<AppState>>,
60 auth: SessionUser,
61) -> Result<Json<Vec<PimCollectionInfo>>, ApiError> {
62 let me = &auth.user;
63 let pid = state.db.principal_of(me.id).await?;
64 state.db.pim_ensure_defaults(pid).await?;
65 let mut out = Vec::new();
66 for kind in [PimKind::Calendar, PimKind::AddressBook] {
67 for c in state.db.pim_collections(pid, kind).await? {
68 if kind == PimKind::Calendar && c.slug == INBOX {
69 continue;
70 }
71 out.push(PimCollectionInfo {
72 id: c.id,
73 kind: wire_kind(kind),
74 name: name_of(&c),
75 url: collection_href(&me.name, kind, &c.slug, None),
76 owner: me.name.clone(),
77 mode: None,
78 });
79 }
80 for (c, owner, mode) in state.db.pim_shared_collections(me.id, kind).await? {
81 out.push(PimCollectionInfo {
82 id: c.id,
83 kind: wire_kind(kind),
84 name: name_of(&c),
85 url: collection_href(&me.name, kind, &c.slug, Some(c.id)),
86 owner,
87 mode: Some(mode),
88 });
89 }
90 }
91 Ok(Json(out))
92}
93
94/// The id of a collection the signed-in user owns, or 404.
95async fn own(state: &AppState, auth: &SessionUser, id: i64) -> Result<i64, ApiError> {
96 let pid = state.db.principal_of(auth.user.id).await?;
97 match state.db.pim_collection_by_id(id).await? {
98 // The inbox is not lent: it holds messages, not events.
99 Some((owner, _, c)) if owner == pid && c.slug != INBOX => Ok(id),
100 _ => Err(ApiError::new(StatusCode::NOT_FOUND, "collection not found")),
101 }
102}
103
104/// GET {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}
105pub async fn shares(
106 State(state): State<Arc<AppState>>,
107 auth: SessionUser,
108 AxumPath(id): AxumPath<i64>,
109) -> Result<Json<Vec<PimShareInfo>>, ApiError> {
110 let id = own(&state, &auth, id).await?;
111 let out = state
112 .db
113 .pim_shares(id)
114 .await?
115 .into_iter()
116 .map(|(user_id, user_name, mode)| PimShareInfo {
117 user_id,
118 user_name,
119 mode,
120 })
121 .collect();
122 Ok(Json(out))
123}
124
125/// POST {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}
126pub async fn share(
127 State(state): State<Arc<AppState>>,
128 auth: SessionUser,
129 AxumPath(id): AxumPath<i64>,
130 Json(body): Json<CreatePimShare>,
131) -> Result<Json<PimShareInfo>, ApiError> {
132 let id = own(&state, &auth, id).await?;
133 let user = state
134 .db
135 .pim_principal(body.user.trim())
136 .await?
137 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "user not found"))?;
138 let Some(user_id) = user.user_id else {
139 return Err(ApiError::new(StatusCode::NOT_FOUND, "user not found"));
140 };
141 if user_id == auth.user.id {
142 return Err(ApiError::new(
143 StatusCode::BAD_REQUEST,
144 "a collection cannot be shared with its owner",
145 ));
146 }
147 state.db.pim_set_share(id, user_id, body.mode).await?;
148 Ok(Json(PimShareInfo {
149 user_id,
150 user_name: user.name,
151 mode: body.mode,
152 }))
153}
154
155/// DELETE {PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}
156pub async fn unshare(
157 State(state): State<Arc<AppState>>,
158 auth: SessionUser,
159 AxumPath((id, user_id)): AxumPath<(i64, i64)>,
160) -> Result<Json<OkResp>, ApiError> {
161 let id = own(&state, &auth, id).await?;
162 if !state.db.pim_remove_share(id, user_id).await? {
163 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
164 }
165 Ok(Json(OkResp {}))
166}
167
168/// A collection the signed-in user may read: its owner principal, kind, the
169/// collection, and whether they may also write it. The inbox is not one.
170async fn reachable(
171 state: &AppState,
172 auth: &SessionUser,
173 id: i64,
174) -> Result<(i64, PimKind, PimCollection, bool), ApiError> {
175 let not_found = || ApiError::new(StatusCode::NOT_FOUND, "collection not found");
176 let pid = state.db.principal_of(auth.user.id).await?;
177 let (owner, kind, c) = state
178 .db
179 .pim_collection_by_id(id)
180 .await?
181 .ok_or_else(not_found)?;
182 if c.slug == INBOX {
183 return Err(not_found());
184 }
185 if owner == pid {
186 return Ok((owner, kind, c, true));
187 }
188 match state
189 .db
190 .pim_shared_collection(auth.user.id, kind, id)
191 .await?
192 {
193 Some((_, _, mode)) => Ok((owner, kind, c, mode != PimShareMode::Ro)),
194 None => Err(not_found()),
195 }
196}
197
198fn extension(kind: PimKind) -> &'static str {
199 match kind {
200 PimKind::Calendar => "ics",
201 PimKind::AddressBook => "vcf",
202 }
203}
204
205fn link_info(link: &PimLink, kind: PimKind) -> PimLinkInfo {
206 PimLinkInfo {
207 id: link.id,
208 path: format!("{FEED}/{}.{}", link.token, extension(kind)),
209 busy_only: link.busy_only,
210 created_at: link.created_at.clone(),
211 expires_at: link.expires_at.clone(),
212 has_password: link.password_hash.is_some(),
213 }
214}
215
216/// GET {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}
217pub async fn links(
218 State(state): State<Arc<AppState>>,
219 auth: SessionUser,
220 AxumPath(id): AxumPath<i64>,
221) -> Result<Json<Vec<PimLinkInfo>>, ApiError> {
222 let id = own(&state, &auth, id).await?;
223 let (_, kind, _) = state
224 .db
225 .pim_collection_by_id(id)
226 .await?
227 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
228 let links = state.db.pim_links(id).await?;
229 Ok(Json(links.iter().map(|l| link_info(l, kind)).collect()))
230}
231
232/// POST {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}
233pub async fn create_link(
234 State(state): State<Arc<AppState>>,
235 auth: SessionUser,
236 AxumPath(id): AxumPath<i64>,
237 Json(body): Json<CreatePimLink>,
238) -> Result<Json<PimLinkInfo>, ApiError> {
239 let id = own(&state, &auth, id).await?;
240 let (_, kind, _) = state
241 .db
242 .pim_collection_by_id(id)
243 .await?
244 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
245 if body.busy_only && kind != PimKind::Calendar {
246 return Err(ApiError::new(
247 StatusCode::BAD_REQUEST,
248 "busy_only needs a calendar",
249 ));
250 }
251 // As for shares: an unparseable expiry would never expire.
252 if let Some(e) = &body.expires_at
253 && chrono::DateTime::parse_from_rfc3339(e).is_err()
254 {
255 return Err(ApiError::localized(
256 StatusCode::BAD_REQUEST,
257 "expires_at must be an RFC 3339 timestamp",
258 "err_bad_expires_at",
259 ));
260 }
261 let password_hash = match body.password.as_deref().map(str::trim) {
262 Some(pw) if !pw.is_empty() => {
263 validate_password(pw)?;
264 Some(hash_password(pw).await?)
265 }
266 _ => None,
267 };
268 let link = state
269 .db
270 .pim_create_link(
271 id,
272 &auth::short_token(),
273 body.busy_only,
274 body.expires_at.as_deref(),
275 password_hash.as_deref(),
276 )
277 .await?;
278 Ok(Json(link_info(&link, kind)))
279}
280
281/// DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}
282pub async fn delete_link(
283 State(state): State<Arc<AppState>>,
284 auth: SessionUser,
285 AxumPath((id, link_id)): AxumPath<(i64, i64)>,
286) -> Result<Json<OkResp>, ApiError> {
287 let id = own(&state, &auth, id).await?;
288 if !state.db.pim_delete_link(id, link_id).await? {
289 return Err(ApiError::new(StatusCode::NOT_FOUND, "link not found"));
290 }
291 Ok(Json(OkResp {}))
292}
293
294/// GET {FEED}/{token}
295pub async fn feed(
296 State(state): State<Arc<AppState>>,
297 AxumPath(file): AxumPath<String>,
298 headers: HeaderMap,
299) -> Result<Response, ApiError> {
300 let token = file
301 .strip_suffix(".ics")
302 .or_else(|| file.strip_suffix(".vcf"))
303 .unwrap_or(&file);
304 let Some(link) = state.db.pim_link_by_token(token).await? else {
305 return Ok(StatusCode::NOT_FOUND.into_response());
306 };
307 if link.is_expired() {
308 return Ok(StatusCode::GONE.into_response());
309 }
310 // Basic with the user name ignored, like a protected share mount.
311 if let Some(hash) = link.password_hash.clone() {
312 let Some((_, password)) = auth::basic_credentials(&headers) else {
313 return Ok(challenge());
314 };
315 let (pw, id, tok) = (password.clone(), link.id, link.token.clone());
316 // A negative realm: share ids are positive, and one share's password
317 // must never open a feed with the same id.
318 let ok = auth::verify_cached(-link.id, "", &password, move || async move {
319 auth::throttle(&tok).await;
320 let ok = auth::verify_password_async(&pw, &hash).await;
321 auth::record_login(&tok, ok);
322 ok.then_some(id)
323 })
324 .await;
325 if ok.is_none() {
326 return Ok(challenge());
327 }
328 }
329 let Some((_, kind, col)) = state.db.pim_collection_by_id(link.collection_id).await? else {
330 return Ok(StatusCode::NOT_FOUND.into_response());
331 };
332 let etag = format!(
333 "\"feed-{}-{}{}\"",
334 col.id,
335 col.seq,
336 if link.busy_only { "-busy" } else { "" }
337 );
338 let unchanged = headers
339 .get(IF_NONE_MATCH)
340 .and_then(|v| v.to_str().ok())
341 .is_some_and(|v| {
342 v.split(',')
343 .map(|t| t.trim().trim_start_matches("W/"))
344 .any(|t| t == etag || t == "*")
345 });
346 if unchanged {
347 return Ok((StatusCode::NOT_MODIFIED, [(ETAG, etag)]).into_response());
348 }
349 let detail = match link.busy_only {
350 true => Detail::Busy,
351 false => Detail::Public,
352 };
353 let body = render(&state, kind, &col, detail).await?;
354 Ok((
355 [
356 (CONTENT_TYPE, mime(kind).to_string()),
357 (ETAG, etag),
358 (CACHE_CONTROL, "no-cache".to_string()),
359 ],
360 body,
361 )
362 .into_response())
363}
364
365fn mime(kind: PimKind) -> &'static str {
366 match kind {
367 PimKind::Calendar => "text/calendar; charset=utf-8",
368 PimKind::AddressBook => "text/vcard; charset=utf-8",
369 }
370}
371
372async fn render(
373 state: &AppState,
374 kind: PimKind,
375 col: &PimCollection,
376 detail: Detail,
377) -> Result<String, ApiError> {
378 let objects = state.db.pim_objects_with_data(col.id).await?;
379 let texts: Vec<String> = objects
380 .into_iter()
381 .map(|(_, d)| String::from_utf8_lossy(&d).into_owned())
382 .collect();
383 let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
384 Ok(match kind {
385 PimKind::Calendar => bundle::calendar(&texts, Some(&name_of(col)), detail),
386 PimKind::AddressBook => bundle::cards(&texts),
387 })
388}
389
390/// GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}
391pub async fn export(
392 State(state): State<Arc<AppState>>,
393 auth: SessionUser,
394 AxumPath(id): AxumPath<i64>,
395) -> Result<Response, ApiError> {
396 let (_, kind, col, _) = reachable(&state, &auth, id).await?;
397 let body = render(&state, kind, &col, Detail::All).await?;
398 let file = format!(
399 "{}.{}",
400 name_of(&col).replace(['/', '\\'], "_"),
401 extension(kind)
402 );
403 Ok((
404 [
405 (CONTENT_TYPE, mime(kind).to_string()),
406 (CONTENT_DISPOSITION, disposition("attachment", &file)),
407 ],
408 body,
409 )
410 .into_response())
411}
412
413/// POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}: a new file in a writable root.
414pub async fn export_to_root(
415 State(state): State<Arc<AppState>>,
416 auth: SessionUser,
417 AxumPath(id): AxumPath<i64>,
418 Json(target): Json<PimRootFile>,
419) -> Result<Json<OkResp>, ApiError> {
420 let (_, kind, col, _) = reachable(&state, &auth, id).await?;
421 let root = require_rw_root(&auth.roots, target.root_id)?;
422 let body = render(&state, kind, &col, Detail::All).await?;
423 let (server_root, root_path) = (state.root.clone(), root.path.clone());
424 blocking(move || {
425 fs::create_file(&server_root, &root_path, &target.path)?;
426 fs::save_file(
427 &server_root,
428 &root_path,
429 &target.path,
430 body.as_bytes(),
431 None,
432 )
433 })
434 .await?;
435 Ok(Json(OkResp {}))
436}
437
438/// POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}
439///
440/// Each object goes through the checks of a PUT and is skipped where a PUT
441/// would fail. An object whose UID the collection already has replaces it.
442/// Nothing is sent to attendees or organizers.
443pub async fn import(
444 State(state): State<Arc<AppState>>,
445 auth: SessionUser,
446 AxumPath(id): AxumPath<i64>,
447 headers: HeaderMap,
448 body: Body,
449) -> Result<Json<PimImportResult>, ApiError> {
450 let (owner, kind, col, writable) = reachable(&state, &auth, id).await?;
451 if !writable {
452 return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
453 }
454 let too_large = || ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "file too large");
455 let json = headers
456 .get(CONTENT_TYPE)
457 .and_then(|v| v.to_str().ok())
458 .is_some_and(|t| t.starts_with("application/json"));
459 let data = if json {
460 let raw = axum::body::to_bytes(body, 64 * 1024)
461 .await
462 .map_err(|_| too_large())?;
463 let file: PimRootFile = serde_json::from_slice(&raw)
464 .map_err(|_| ApiError::new(StatusCode::BAD_REQUEST, "invalid JSON body"))?;
465 read_root_file(&state, &auth, file).await?
466 } else {
467 axum::body::to_bytes(body, MAX_IMPORT)
468 .await
469 .map_err(|_| too_large())?
470 .to_vec()
471 };
472 // Old phone exports are often Latin-1.
473 let text = String::from_utf8(data)
474 .unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect());
475 // From the content, so importing the same file twice updates.
476 let mut new_uid = |text: &str| crate::hex(&Sha256::digest(text))[..32].to_string();
477 let parts = match kind {
478 PimKind::Calendar => bundle::split_calendar(&text, &mut new_uid),
479 PimKind::AddressBook => bundle::split_cards(&text, &mut new_uid),
480 };
481 if parts.is_empty() {
482 return Err(ApiError::new(
483 StatusCode::BAD_REQUEST,
484 "the file holds no calendar or address objects",
485 ));
486 }
487
488 let _lock = pim_schedule::LOCK.lock().await;
489 let dir = Directory::load(&state).await?;
490 let owner = dir
491 .get(owner)
492 .cloned()
493 .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
494 let supported: Vec<&str> = col.components.split(',').collect();
495 let now = chrono::Utc::now();
496 let mut result = PimImportResult {
497 created: 0,
498 updated: 0,
499 skipped_total: 0,
500 skipped: Vec::new(),
501 };
502 let mut skip = |uid: Option<String>, reason: &str| {
503 result.skipped_total += 1;
504 if result.skipped.len() < MAX_SKIPPED {
505 result.skipped.push(PimSkipped {
506 uid,
507 reason: reason.to_string(),
508 });
509 }
510 };
511 // Names given in this import, so a UID seen twice updates its first copy.
512 let mut names: HashMap<String, String> = HashMap::new();
513 let mut ops = Vec::new();
514 let (mut created, mut updated) = (0, 0);
515 for part in parts {
516 let checked = match kind {
517 PimKind::Calendar => object::calendar(part.as_bytes(), &supported)
518 .map(|o| (o.uid, o.component.to_string())),
519 PimKind::AddressBook => {
520 object::vcard(part.as_bytes()).map(|u| (u.unwrap_or_default(), "VCARD".into()))
521 }
522 };
523 let (uid, component) = match checked {
524 Ok(v) => v,
525 Err(invalid) => {
526 skip(None, &invalid.condition().name);
527 continue;
528 }
529 };
530 let data = match kind {
531 PimKind::Calendar => {
532 object::with_dtstamp(part.as_bytes(), now).unwrap_or_else(|| part.into_bytes())
533 }
534 PimKind::AddressBook => part.into_bytes(),
535 };
536 let existing = match names.get(&uid) {
537 Some(name) => Some(name.clone()),
538 None => state.db.pim_uid_holder(col.id, &uid, "").await?,
539 };
540 let name = existing.clone().unwrap_or_else(|| object_name(&uid, kind));
541 let schedule_tag = match kind {
542 PimKind::Calendar => {
543 match pim_schedule::import_tag(&state, &dir, &owner, (col.id, &name), &data).await?
544 {
545 Ok(tag) => tag,
546 Err(condition) => {
547 skip(Some(uid), &condition.name);
548 continue;
549 }
550 }
551 }
552 PimKind::AddressBook => None,
553 };
554 match existing {
555 Some(_) => updated += 1,
556 None => created += 1,
557 }
558 names.insert(uid.clone(), name.clone());
559 ops.push(PimOp::Put {
560 collection_id: col.id,
561 obj: PimObject {
562 name,
563 uid,
564 component,
565 etag: etag_of(&data),
566 schedule_tag,
567 ..Default::default()
568 },
569 data,
570 });
571 }
572 state.db.pim_apply(&ops).await?;
573 result.created = created;
574 result.updated = updated;
575 Ok(Json(result))
576}
577
578async fn read_root_file(
579 state: &AppState,
580 auth: &SessionUser,
581 file: PimRootFile,
582) -> Result<Vec<u8>, ApiError> {
583 let root = find_root(&auth.roots, file.root_id)?;
584 let (server_root, root_path) = (state.root.clone(), root.path.clone());
585 blocking(move || {
586 let full = fs::resolve_path(&server_root, &root_path, &file.path)?;
587 let meta = std::fs::metadata(&full)?;
588 if meta.is_dir() {
589 return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
590 }
591 if meta.len() > MAX_IMPORT as u64 {
592 return Err(ApiError::new(
593 StatusCode::PAYLOAD_TOO_LARGE,
594 "file too large",
595 ));
596 }
597 Ok(std::fs::read(&full)?)
598 })
599 .await
600}
601