CalDAV/CardDAV: photos without a cache, system address book export
- Contact photos are re-encoded on each request when there is no thumbnail cache; the ETag check still comes before any decode - One process-wide thumbnail concurrency limit, shared by the cached and the uncached path - GET and POST /api/pim/system/export download the system address book or save it into a writable root; export helpers shared with collections Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MREADME.md
@@ -248,9 +248,10 @@ February 29 shows on February 28 in other years.
**Contact photos**: `GET /api/pim/collections/<id>/objects/<name>/photo`
returns the photo of a contact you can read as a WebP of at most 256
pixels, like a file thumbnail. It needs the thumbnail cache (`--cache`).
Only a photo stored inside the contact counts. A photo given as a web
address is never fetched.
pixels, like a file thumbnail. With the thumbnail cache (`--cache`) the
result is kept; without it the photo is made on each request. Only a
photo stored inside the contact counts. A photo given as a web address is
never fetched.
### Sharing
@@ -343,8 +344,8 @@ your folders. You need write access: your own collection, or a `rw` or
book as one file, with every event in full, private ones included. `POST` with
`{"root_id": <id>, "path": "<path>"}` saves it as a new file into a
writable folder. An existing file is not overwritten. Lent collections can
be exported too. The system address book and the birthday calendar
cannot.
be exported too. `GET` and `POST /api/pim/system/export` do the same for
the system address book. The birthday calendar cannot be exported.
### Invitations
Mapi-types/src/lib.rs
@@ -88,6 +88,9 @@ pub const IMPORT_SUFFIX: &str = "/import";
/// `{PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}`: `GET` downloads the collection as
/// one file; `POST` with a [`PimRootFile`] saves it into a root.
pub const EXPORT_SUFFIX: &str = "/export";
/// The system address book, which has no collection id: `GET` downloads it,
/// `POST` with a [`PimRootFile`] saves it into a root.
pub const PIM_SYSTEM_EXPORT: &str = "/api/pim/system/export";
/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}`: a
/// contact's photo as a WebP thumbnail.
pub const OBJECTS_SUFFIX: &str = "/objects";
Mpimdav/README.md
@@ -406,9 +406,11 @@ the output unchanged. Only line endings become CRLF.
URI. A photo given as a URL is never fetched: a contact could then make
the server request any address.
- The server never serves the stored bytes. They come from a client and
could be HTML or SVG with script. The thumbnail cache re-encodes them
as WebP, keyed by the object's ETag, so an edited contact gets a new
image. Without a cache there are no photos.
could be HTML or SVG with script. They are re-encoded as WebP. The
thumbnail cache keeps the result, keyed by the object's ETag, so an
edited contact gets a new image. Without a cache the server re-encodes
on each request. The ETag is the object's, so a client that already has
the photo gets a 304 without a decode.
### Birthdays and anniversaries
Mserver/src/api/mod.rs
@@ -5,8 +5,8 @@ use api_types::{
AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS,
AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, DAV, DAV_SHARE, EXPORT_SUFFIX, FEED, FILES,
FINISH_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, OBJECTS_SUFFIX, PHOTO_SUFFIX, PIM, PIM_COLLECTIONS,
SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX, WELL_KNOWN_CALDAV,
WELL_KNOWN_CARDDAV,
PIM_SYSTEM_EXPORT, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX,
WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -198,6 +198,10 @@ pub fn router(state: Arc<AppState>) -> Router {
&pim_export,
get(pim_api::export).post(pim_api::export_to_root),
)
.route(
PIM_SYSTEM_EXPORT,
get(pim_api::export_system).post(pim_api::export_system_to_root),
)
.route(&pim_photo, get(pim_api::photo))
.route(ADMIN_PIM_LINKS, get(admin::list_pim_links))
.route(&admin_pim_link, delete(admin::delete_pim_link))
Mserver/src/api/pim.rs
@@ -520,7 +520,7 @@ fn generated_collection(
type Members = Vec<(PimObject, Vec<u8>)>;
/// The generated system address book: one card per visible principal.
async fn directory(state: &AppState) -> Result<(PimCollection, Members), ApiError> {
pub(super) async fn directory(state: &AppState) -> Result<(PimCollection, Members), ApiError> {
let mut members = Vec::new();
for p in state.db.pim_principals().await? {
let uuid = principal_uuid(p.id);
Mserver/src/api/pim_api.rs
@@ -7,6 +7,7 @@
//! - `DELETE {PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}` — revoke a feed
//! - `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}` — import a file
//! - `GET`, `POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download, or save into a root
//! - `GET`, `POST {PIM_SYSTEM_EXPORT}` — the same for the system address book
//!
//! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}` — a contact's photo
//!
@@ -199,9 +200,9 @@ async fn reachable(
/// GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}
///
/// Always a WebP made by the thumbnail cache, never the stored bytes: those
/// come from a client and could be HTML or SVG with script. So without a
/// cache there are no photos, as there are no thumbnails.
/// Always a WebP thumbnail, never the stored bytes: those come from a client
/// and could be HTML or SVG with script. Without a thumbnail cache it is made
/// on each request; a matching ETag still skips the decode.
pub async fn photo(
State(state): State<Arc<AppState>>,
auth: SessionUser,
@@ -209,9 +210,6 @@ pub async fn photo(
headers: HeaderMap,
) -> Result<Response, ApiError> {
let no_photo = || ApiError::new(StatusCode::NOT_FOUND, "no photo");
let Some(thumbs) = state.thumbs.as_ref() else {
return Err(no_photo());
};
let (_, kind, _, _) = reachable(&state, &auth, id).await?;
if kind != PimKind::AddressBook {
return Err(no_photo());
@@ -225,10 +223,15 @@ pub async fn photo(
return Ok((StatusCode::NOT_MODIFIED, cached).into_response());
}
let image = contact::photo(&String::from_utf8_lossy(&data)).ok_or_else(no_photo)?;
let bytes = thumbs
.of_bytes(&format!("pim-photo {}", obj.etag), image)
.await
.ok_or_else(no_photo)?;
let bytes = match &state.thumbs {
Some(thumbs) => {
thumbs
.of_bytes(&format!("pim-photo {}", obj.etag), image)
.await
}
None => crate::thumb::of_image(image).await,
}
.ok_or_else(no_photo)?;
Ok((cached, [(CONTENT_TYPE, "image/webp")], bytes).into_response())
}
@@ -432,19 +435,7 @@ pub async fn export(
) -> Result<Response, ApiError> {
let (_, kind, col, _) = reachable(&state, &auth, id).await?;
let body = render(&state, kind, &col, Detail::All).await?;
let file = format!(
"{}.{}",
name_of(&col).replace(['/', '\\'], "_"),
extension(kind)
);
Ok((
[
(CONTENT_TYPE, mime(kind).to_string()),
(CONTENT_DISPOSITION, disposition("attachment", &file)),
],
body,
)
.into_response())
Ok(download(kind, &name_of(&col), body))
}
/// POST {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}: a new file in a writable root.
@@ -455,8 +446,58 @@ pub async fn export_to_root(
Json(target): Json<PimRootFile>,
) -> Result<Json<OkResp>, ApiError> {
let (_, kind, col, _) = reachable(&state, &auth, id).await?;
let root = require_rw_root(&auth.roots, target.root_id)?;
let body = render(&state, kind, &col, Detail::All).await?;
save(&state, &auth, target, body).await
}
/// GET {PIM_SYSTEM_EXPORT}
pub async fn export_system(
State(state): State<Arc<AppState>>,
_auth: SessionUser,
) -> Result<Response, ApiError> {
let (col, body) = system_cards(&state).await?;
Ok(download(PimKind::AddressBook, &name_of(&col), body))
}
/// POST {PIM_SYSTEM_EXPORT}: a new file in a writable root.
pub async fn export_system_to_root(
State(state): State<Arc<AppState>>,
auth: SessionUser,
Json(target): Json<PimRootFile>,
) -> Result<Json<OkResp>, ApiError> {
let (_, body) = system_cards(&state).await?;
save(&state, &auth, target, body).await
}
async fn system_cards(state: &AppState) -> Result<(PimCollection, String), ApiError> {
let (col, members) = crate::api::pim::directory(state).await?;
let texts: Vec<String> = members
.into_iter()
.map(|(_, d)| String::from_utf8_lossy(&d).into_owned())
.collect();
let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
Ok((col, bundle::cards(&texts)))
}
fn download(kind: PimKind, name: &str, body: String) -> Response {
let file = format!("{}.{}", name.replace(['/', '\\'], "_"), extension(kind));
(
[
(CONTENT_TYPE, mime(kind).to_string()),
(CONTENT_DISPOSITION, disposition("attachment", &file)),
],
body,
)
.into_response()
}
async fn save(
state: &AppState,
auth: &SessionUser,
target: PimRootFile,
body: String,
) -> Result<Json<OkResp>, ApiError> {
let root = require_rw_root(&auth.roots, target.root_id)?;
let (server_root, root_path) = (state.root.clone(), root.path.clone());
blocking(move || {
fs::create_file(&server_root, &root_path, &target.path)?;
Mserver/src/thumb.rs
@@ -5,6 +5,7 @@
use std::io::{BufRead, Cursor, Seek};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::{Duration, SystemTime};
use api_types::FileKind;
@@ -45,11 +46,20 @@ const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024;
/// often black.
const VIDEO_SEEK: &str = "1";
/// Caps concurrent generation, with or without a cache. Measured throughput
/// flattens past 8 workers, and the cap keeps one big folder from starving
/// the server.
static LIMIT: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(workers()));
fn workers() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(8)
}
pub struct Thumbs {
dir: PathBuf,
/// Caps concurrent generation. Measured throughput flattens past 8
/// workers, and the cap keeps one big folder from starving the server.
limit: Semaphore,
/// Whether `ffmpeg` answered at startup. Only video thumbnails need it.
ffmpeg: bool,
}
@@ -80,16 +90,9 @@ impl Thumbs {
std::fs::write(&probe, b"")?;
let _ = std::fs::remove_file(&probe);
let workers = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(8);
let workers = workers();
tracing::info!(dir = %dir.display(), workers, ffmpeg, "thumbnail cache ready");
Ok(Self {
dir,
limit: Semaphore::new(workers),
ffmpeg,
})
Ok(Self { dir, ffmpeg })
}
/// The thumbnail for `src`, making it if the cache does not have it.
@@ -109,7 +112,7 @@ impl Thumbs {
// The permit is held across generation only, never across the disk
// read above: a cache hit must not queue behind a 36 MP decode.
let _permit = self.limit.acquire().await.ok()?;
let _permit = LIMIT.acquire().await.ok()?;
// Another request may have finished it while we waited.
if let Some(hit) = read_hit(&path).await {
return hit;
@@ -144,11 +147,7 @@ impl Thumbs {
if let Some(hit) = read_hit(&path).await {
return hit;
}
let _permit = self.limit.acquire().await.ok()?;
let made = tokio::task::spawn_blocking(move || bytes_thumb(&data))
.await
.ok()
.flatten();
let made = of_image(data).await;
store(&path, made.as_deref().unwrap_or(&[])).await;
made
}
@@ -234,6 +233,15 @@ fn image_thumb(src: &Path) -> Option<Vec<u8>> {
thumb_of(|| ImageReader::open(src).ok()?.with_guessed_format().ok())
}
/// The thumbnail of an image held in memory, made without a cache.
pub async fn of_image(data: Vec<u8>) -> Option<Vec<u8>> {
let _permit = LIMIT.acquire().await.ok()?;
tokio::task::spawn_blocking(move || bytes_thumb(&data))
.await
.ok()
.flatten()
}
fn bytes_thumb(data: &[u8]) -> Option<Vec<u8>> {
thumb_of(|| {
ImageReader::new(Cursor::new(data))
@@ -476,7 +484,6 @@ mod tests {
fn an_edit_changes_the_cache_key() {
let t = Thumbs {
dir: PathBuf::from("/cache"),
limit: Semaphore::new(1),
ffmpeg: false,
};
let p = Path::new("/data/a.jpg");
@@ -493,7 +500,6 @@ mod tests {
fn the_key_fans_out_into_a_bucket() {
let t = Thumbs {
dir: PathBuf::from("/cache"),
limit: Semaphore::new(1),
ffmpeg: false,
};
let p = t.entry_path(Path::new("/data/a.jpg"), 1, 1);
Mserver/tests/api_pim_derived.rs
@@ -183,7 +183,7 @@ fn walk(dir: &std::path::Path) -> usize {
}
#[tokio::test]
async fn no_photos_without_a_cache() {
async fn photos_without_a_cache() {
let pim = Pim::new(Env::new().await).await;
pim.put(
"alice",
@@ -191,12 +191,38 @@ async fn no_photos_without_a_cache() {
&contact_with_photo("anna"),
)
.await;
let broken = pim
.req(
"alice",
"PUT",
&format!("{BOOK}broken.vcf"),
"0",
&contact("broken", "PHOTO;ENCODING=b;TYPE=PNG:bm90IGFuIGltYWdl\r\n"),
)
.await;
let etag = broken.header("etag").unwrap();
let id = pim.id(BOOK).await;
let photo = |name: &str| format!("/api/pim/collections/{id}/objects/{name}/photo");
let r = pim.alice.get(&photo("anna.vcf")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(&r.body[..4], b"RIFF");
// The image does not decode, so a 304 proves the ETag check comes first.
assert_eq!(
pim.alice.get(&photo("broken.vcf")).await.status,
StatusCode::NOT_FOUND
);
let r = pim
.alice
.get(&format!("/api/pim/collections/{id}/objects/anna.vcf/photo"))
.raw(
Method::GET,
&photo("broken.vcf"),
&[("if-none-match", &etag)],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
assert_eq!(r.status, StatusCode::NOT_MODIFIED);
}
#[tokio::test]
Mserver/tests/api_pim_io.rs
@@ -404,4 +404,18 @@ async fn root_files_and_round_trip() {
assert_eq!(r["created"], 2, "{r}");
let r = io.import(book, cards).await;
assert_eq!(r["updated"], 2, "{r}");
// The system address book has no collection id of its own.
let r = io.alice.get("/api/pim/system/export").await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.header("content-disposition").unwrap().contains(".vcf"));
let people = r.text();
assert!(people.contains("FN:alice"), "{people}");
let into = json!({ "root_id": docs, "path": "people.vcf" });
let r = io.alice.post_json("/api/pim/system/export", &into).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
std::fs::read_to_string(io.env.file("docs/people.vcf")).unwrap(),
people
);
}