diff --git a/src/interfaces/nextcloud/avatar_handler.rs b/src/interfaces/nextcloud/avatar_handler.rs index ad8cfcff..e5af8a3a 100644 --- a/src/interfaces/nextcloud/avatar_handler.rs +++ b/src/interfaces/nextcloud/avatar_handler.rs @@ -3,22 +3,174 @@ use axum::{ http::{StatusCode, header}, response::{IntoResponse, Response}, }; +use base64::Engine; use std::sync::Arc; use crate::common::di::AppState; +/// Re-encode WebP image bytes as PNG. Returns `None` on decode/encode +/// failure (treated upstream as "fall through to SVG" — a bad stored +/// blob shouldn't break the rendering pipeline). PNG is universal: +/// every NC client surface (Qt desktop without `qtimageformats`, +/// older Android/iOS image stacks) decodes it. WebP, JPEG and GIF +/// pass through this function unchanged because they're either +/// already PNG-equivalent for compat purposes (JPEG/GIF) or the only +/// problematic format we know to convert (WebP). +fn webp_to_png(webp_bytes: &[u8]) -> Option> { + use image::ImageFormat; + use std::io::Cursor; + let img = image::load_from_memory_with_format(webp_bytes, ImageFormat::WebP).ok()?; + let mut out = Vec::with_capacity(webp_bytes.len()); + img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png) + .ok()?; + Some(out) +} + +/// Parse an `auth.users.image` data URI of the form +/// `data:;base64,` into its `(mime, decoded_bytes)` +/// parts. +/// +/// Returns `None` when the input is not a data URI (e.g. an external +/// URL, a relative path, or anything we don't know how to render +/// inline) or when the base64 payload is malformed. Callers fall back +/// to the SVG initials avatar in that case — silently, since a bad +/// stored value shouldn't break the rendering pipeline for the +/// requesting client. +fn parse_data_uri(uri: &str) -> Option<(String, Vec)> { + // `data:image/png;base64,iVBORw0KGgo…` — split at the first comma + // because the payload can contain `=` padding which we must not + // accidentally include in the header chunk. + let rest = uri.strip_prefix("data:")?; + let (header_part, payload) = rest.split_once(',')?; + // We require the explicit `;base64` flag — Nextcloud's standard + // image storage format. Plain (url-encoded) data URIs are + // technically valid per RFC 2397 but in practice we never see + // them from any OxiCloud write path. + let header_part = header_part.strip_suffix(";base64")?; + let mime = if header_part.is_empty() { + "application/octet-stream".to_string() + } else { + header_part.to_string() + }; + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload.trim()) + .ok()?; + Some((mime, bytes)) +} + +/// GET /remote.php/dav/avatars/{user}/{size}.png +/// +/// NextCloud-DAV-shaped avatar URL used by the NC desktop client +/// (and several mobile clients). When the user has a stored image +/// in `auth.users.image` (typically a `data:image/...;base64,…` +/// URI written by the profile-picture upload flow), the decoded +/// bytes are returned verbatim with their original MIME type. +/// Otherwise we fall through to the same SVG initials payload as +/// [`handle_avatar`] — Qt's `QImage` and the NC mobile image +/// stacks auto-detect format from `Content-Type`, so SVG-via-`.png` +/// URL is harmless in practice. +/// +/// The `size` segment arrives as `"128.png"` so we strip the +/// suffix before parsing — a missing suffix is tolerated so this +/// also works if a client requests `/avatars/admin/128` against +/// the DAV route (some older clients omit the extension). +pub async fn handle_dav_avatar( + state: State>, + Path((username, size_with_ext)): Path<(String, String)>, +) -> Response { + let size_str = size_with_ext.strip_suffix(".png").unwrap_or(&size_with_ext); + let size: u32 = size_str.parse().unwrap_or(64); + handle_avatar(state, Path((username, size))).await +} + /// GET /index.php/avatar/{user}/{size} /// -/// Returns an SVG avatar with the user's initials on a colored background. +/// Resolution order: +/// 1. Look up the user; if `auth.users.image` holds a `data:…;base64,…` +/// URI, decode and serve the original bytes verbatim. This is the +/// profile-picture path used by NC desktop / mobile. +/// 2. Otherwise (no row, no image, or unparseable value), generate an +/// SVG tile with the username's initials on a deterministic color. +/// +/// Auth lookup failures fall through to the SVG so a transient DB +/// hiccup doesn't break the rendering pipeline for the requesting +/// client — the avatar is decorative, not security-critical. pub async fn handle_avatar( - State(_state): State>, + State(state): State>, Path((username, size)): Path<(String, u32)>, ) -> Response { let size = size.clamp(16, 1024); - let initials = extract_initials(&username); - let color = pick_color(&username); - let font_size = (size as f32 * 0.45) as u32; + // ── Stored profile image — preferred when present ─────────── + if let Some(auth_svc) = state.auth_service.as_ref() + && let Ok(user) = auth_svc + .auth_application_service + .get_user_by_username(&username) + .await + && let Some(image_uri) = user.image.as_deref() + && let Some((mime, bytes)) = parse_data_uri(image_uri) + { + // WebP is OxiCloud's storage format of choice (smaller files, + // better quality at a given size) but NextCloud clients have + // patchy WebP support — older Qt-based desktop builds, some + // mobile image stacks. Transcode to PNG before serving on the + // NC surface so every client renders it. PNG is bigger on the + // wire but small enough at avatar dimensions that the + // tradeoff is worth it. Decode failure falls through to SVG. + let (final_mime, final_bytes): (&str, Vec) = if mime == "image/webp" { + match webp_to_png(&bytes) { + Some(png) => ("image/png", png), + None => return svg_initials_response(&username, size), + } + } else { + // Whatever MIME we stored (`image/png`, `image/jpeg`, + // `image/gif`) is universally supported by NC clients. + // The `mime` String is moved out via `.as_str()` here, so + // bind it locally to keep the borrow alive for the response. + (mime_as_static_str(&mime), bytes) + }; + return ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, final_mime), + // Shorter cache than the SVG fallback because users can + // re-upload their picture at any time — the URL is the + // same so a long immutable cache would pin the old one. + (header::CACHE_CONTROL, "public, max-age=3600"), + ], + final_bytes, + ) + .into_response(); + } + + svg_initials_response(&username, size) +} + +/// Map a runtime MIME string to one of the static `&'static str` +/// variants we expose so the response header can borrow it for the +/// lifetime of the response. +/// +/// Unknown / non-image MIMEs fall through to `application/octet-stream` +/// — clients still render the bytes via format-sniffing, but they +/// also know not to mis-trust the value. +fn mime_as_static_str(mime: &str) -> &'static str { + match mime { + "image/png" => "image/png", + "image/jpeg" => "image/jpeg", + "image/gif" => "image/gif", + "image/webp" => "image/webp", + _ => "application/octet-stream", + } +} + +/// Build the SVG-initials avatar response. Extracted so the stored-image +/// path can fall through to the same payload on parse / decode failure +/// without duplicating the rendering code. +fn svg_initials_response(username: &str, size: u32) -> Response { + let size = size.clamp(16, 1024); + let initials = extract_initials(username); + let color = pick_color(username); + let font_size = (size as f32 * 0.45) as u32; let safe_initials = xml_escape(&initials); let svg = format!( diff --git a/src/interfaces/nextcloud/routes.rs b/src/interfaces/nextcloud/routes.rs index a749390c..a600a6ce 100644 --- a/src/interfaces/nextcloud/routes.rs +++ b/src/interfaces/nextcloud/routes.rs @@ -80,6 +80,13 @@ pub fn nextcloud_routes_with_state(state: Arc) -> Router // Protected routes — require Basic Auth via app passwords. let protected = Router::new() + // Both v1 and v2 of the singular cloud/user endpoint return the + // same payload shape — NC's URL-versioning is a transport + // convention, not a protocol break for this endpoint. Older + // NC clients (and some third-party libraries) still hit v1 + // first; without this route they get a 404 even though the + // handler exists. + .route("/ocs/v1.php/cloud/user", get(ocs_handler::handle_user_info)) .route("/ocs/v2.php/cloud/user", get(ocs_handler::handle_user_info)) .route( "/ocs/v1.php/cloud/users/{userid}", @@ -126,6 +133,14 @@ pub fn nextcloud_routes_with_state(state: Arc) -> Router "/index.php/avatar/{user}/{size}", get(avatar_handler::handle_avatar), ) + // NC desktop + several mobile clients fetch avatars from the + // DAV-shaped URL (with a literal `.png` extension on the + // size segment). Same SVG payload, different URL shape — the + // wrapper handler strips the extension and delegates. + .route( + "/remote.php/dav/avatars/{user}/{size}", + get(avatar_handler::handle_dav_avatar), + ) .route( "/remote.php/dav/files/{user}/{*subpath}", any(handle_dav_files),