feat(nextcloud): use user's image for avatar (#434)
- add /ocs/v1.php/cloud/user route (same as /ocs/v2.php/cloud/user )
- add also /remote.php/dav/avatars/{user}/{size}.png route (used by Nextcloud sync)
note: webp are transcoded in png, no cache but using pragma HTTP header
This commit is contained in:
@@ -3,22 +3,174 @@ use axum::{
|
|||||||
http::{StatusCode, header},
|
http::{StatusCode, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
use base64::Engine;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::common::di::AppState;
|
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<Vec<u8>> {
|
||||||
|
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:<mime>;base64,<payload>` 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<u8>)> {
|
||||||
|
// `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<Arc<AppState>>,
|
||||||
|
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}
|
/// 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(
|
pub async fn handle_avatar(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path((username, size)): Path<(String, u32)>,
|
Path((username, size)): Path<(String, u32)>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let size = size.clamp(16, 1024);
|
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<u8>) = 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 safe_initials = xml_escape(&initials);
|
||||||
|
|
||||||
let svg = format!(
|
let svg = format!(
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>
|
|||||||
|
|
||||||
// Protected routes — require Basic Auth via app passwords.
|
// Protected routes — require Basic Auth via app passwords.
|
||||||
let protected = Router::new()
|
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/v2.php/cloud/user", get(ocs_handler::handle_user_info))
|
||||||
.route(
|
.route(
|
||||||
"/ocs/v1.php/cloud/users/{userid}",
|
"/ocs/v1.php/cloud/users/{userid}",
|
||||||
@@ -126,6 +133,14 @@ pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>
|
|||||||
"/index.php/avatar/{user}/{size}",
|
"/index.php/avatar/{user}/{size}",
|
||||||
get(avatar_handler::handle_avatar),
|
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(
|
.route(
|
||||||
"/remote.php/dav/files/{user}/{*subpath}",
|
"/remote.php/dav/files/{user}/{*subpath}",
|
||||||
any(handle_dav_files),
|
any(handle_dav_files),
|
||||||
|
|||||||
Reference in New Issue
Block a user