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