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