diff --git a/Cargo.lock b/Cargo.lock index 5a3f903..287d574 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,12 +332,24 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -630,6 +642,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -893,12 +914,31 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1434,6 +1474,32 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1637,6 +1703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1650,6 +1717,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -1915,6 +1992,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1967,6 +2057,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -2455,6 +2557,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "siphasher" version = "1.0.2" @@ -3366,6 +3474,7 @@ dependencies = [ "chrono-tz", "cot", "futures", + "image", "multer", "password-auth", "reqwest", @@ -3916,3 +4025,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 32d5a9e..0ef4c88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "web-petting" -version = "0.1.12" +version = "0.1.13" edition = "2024" [dependencies] @@ -14,6 +14,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" serde_json = "1" multer = "3" futures = "0.3" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } tokio = { version = "1", features = ["fs"] } uuid = { version = "1", features = ["v4"] } base64 = "0.22" diff --git a/src/admin.rs b/src/admin.rs index 90edb96..7ee2124 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -6,7 +6,12 @@ use cot::request::extractors::Path; use cot::response::{IntoResponse, Redirect, Response}; use cot::router::{Route, Router}; use cot::session::Session; +use image::ImageFormat; +use image::ImageReader; +use image::codecs::jpeg::JpegEncoder; +use image::imageops::FilterType; use serde::Deserialize; +use std::io::Cursor; use crate::i18n::{Lang, Translations}; use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit}; @@ -14,6 +19,8 @@ use crate::telegram; const SESSION_USER_ID: &str = "user_id"; const SESSION_USER_NAME: &str = "user_name"; +const MAX_UPLOADED_IMAGE_DIMENSION: u32 = 1920; +const UPLOADED_IMAGE_JPEG_QUALITY: u8 = 82; // --------------------------------------------------------------------------- // Helpers @@ -91,12 +98,64 @@ fn has_query_flag(request: &Request, flag: &str) -> bool { fn get_query_param(request: &Request, key: &str) -> Option { let prefix = format!("{}=", key); request.uri().query().and_then(|q| { - q.split('&').find_map(|p| { - p.strip_prefix(&prefix).map(|v| v.to_string()) - }) + q.split('&') + .find_map(|p| p.strip_prefix(&prefix).map(|v| v.to_string())) }) } +fn image_format_from_ext(ext: &str) -> Option { + match ext { + "jpg" | "jpeg" => Some(ImageFormat::Jpeg), + "png" => Some(ImageFormat::Png), + "webp" => Some(ImageFormat::WebP), + _ => None, + } +} + +fn transcode_uploaded_image(data: &[u8], ext: &str) -> cot::Result>> { + let Some(format) = image_format_from_ext(ext) else { + return Ok(None); + }; + + let image = ImageReader::with_format(Cursor::new(data), format) + .decode() + .map_err(|e| cot::Error::internal(e.to_string()))?; + let resized = image.resize( + MAX_UPLOADED_IMAGE_DIMENSION, + MAX_UPLOADED_IMAGE_DIMENSION, + FilterType::Lanczos3, + ); + let rgb = resized.to_rgb8(); + let mut encoded = Vec::new(); + let mut encoder = JpegEncoder::new_with_quality(&mut encoded, UPLOADED_IMAGE_JPEG_QUALITY); + encoder + .encode_image(&rgb) + .map_err(|e| cot::Error::internal(e.to_string()))?; + + Ok(Some(encoded)) +} + +async fn save_uploaded_image( + upload_dir: &str, + file_id: uuid::Uuid, + ext: &str, + data: &[u8], +) -> cot::Result { + if let Some(encoded) = transcode_uploaded_image(data, ext)? { + let path = format!("{}/{}.jpg", upload_dir, file_id); + tokio::fs::write(&path, &encoded) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(path) + } else { + let path = format!("{}/{}.{}", upload_dir, file_id, ext); + tokio::fs::write(&path, data) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(path) + } +} + /// Soft pastel palette for client calendar colors. const CLIENT_COLORS: &[&str] = &[ "#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f", @@ -588,24 +647,27 @@ async fn oidc_start(request: Request, db: Database) -> cot::Result { let site_domain = oidc_setting(&db, "site_domain").await?; if issuer_url.trim().is_empty() || client_id.trim().is_empty() { - return Redirect::new(format!("/admin/login?lang={}&error=sso_provider", lang.code())) - .into_response(); + return Redirect::new(format!( + "/admin/login?lang={}&error=sso_provider", + lang.code() + )) + .into_response(); } let authorization_endpoint = match oidc_discover(&issuer_url, "authorization_endpoint").await { Some(ep) => ep, None => { - return Redirect::new(format!("/admin/login?lang={}&error=sso_provider", lang.code())) - .into_response(); + return Redirect::new(format!( + "/admin/login?lang={}&error=sso_provider", + lang.code() + )) + .into_response(); } }; let state = rand_token(); - let redirect_uri = format!( - "{}/admin/oidc/callback", - site_domain.trim_end_matches('/') - ); + let redirect_uri = format!("{}/admin/oidc/callback", site_domain.trim_end_matches('/')); let redirect_url = format!( "{}?response_type=code&client_id={}&redirect_uri={}&scope=openid+profile&state={}", @@ -678,10 +740,7 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot: } }; - let redirect_uri = format!( - "{}/admin/oidc/callback", - site_domain.trim_end_matches('/') - ); + let redirect_uri = format!("{}/admin/oidc/callback", site_domain.trim_end_matches('/')); // Exchange code for tokens let token_resp = reqwest::Client::new() @@ -743,7 +802,11 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot: // Check group membership let allowed_groups = oidc_setting(&db, "oidc_allowed_groups").await?; if !allowed_groups.trim().is_empty() { - let required: Vec<&str> = allowed_groups.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + let required: Vec<&str> = allowed_groups + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); let user_groups: Vec = claims .get("groups") .and_then(|v| v.as_array()) @@ -755,9 +818,9 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot: }) .unwrap_or_default(); - let has_group = required.iter().any(|r| { - user_groups.iter().any(|ug| ug.eq_ignore_ascii_case(r)) - }); + let has_group = required + .iter() + .any(|r| user_groups.iter().any(|ug| ug.eq_ignore_ascii_case(r))); if !has_group { tracing::warn!( @@ -811,16 +874,16 @@ async fn oidc_callback(request: Request, session: Session, db: Database) -> cot: return Redirect::new(fail("sso_disabled")).into_response(); } - let display = user + let session_name = user .display_name .as_deref() .filter(|s| !s.is_empty()) .unwrap_or(&user.login) .to_string(); - tracing::info!(target: "oidc", username = %user.login, display = %display, "SSO login: session established"); + tracing::info!(target: "oidc", username = %user.login, display_name = %session_name, "SSO login: session established"); session.insert(SESSION_USER_ID, user.id.unwrap()).await?; - session.insert(SESSION_USER_NAME, display).await?; + session.insert(SESSION_USER_NAME, session_name).await?; // Clear the oidc_state cookie let clear_cookie = "oidc_state=; Path=/admin/oidc; HttpOnly; Secure; SameSite=Lax; Max-Age=0"; @@ -868,9 +931,7 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R let mut all_feedbacks: Vec = all_visits .iter() - .filter(|v| { - v.user_id.primary_key().unwrap() == user_id && v.client_feedback.is_some() - }) + .filter(|v| v.user_id.primary_key().unwrap() == user_id && v.client_feedback.is_some()) .map(|v| { let cid: i64 = v.client_id.primary_key().unwrap(); let client_name = clients @@ -2008,7 +2069,6 @@ async fn media_upload_submit( }; let file_id = uuid::Uuid::new_v4(); - let file_path = format!("{}/{}.{}", upload_dir, file_id, ext); let data = field .bytes() @@ -2017,9 +2077,15 @@ async fn media_upload_submit( if data.is_empty() { continue; } - tokio::fs::write(&file_path, &data) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + let file_path = if file_type == "photo" { + save_uploaded_image(&upload_dir, file_id, &ext, &data).await? + } else { + let path = format!("{}/{}.{}", upload_dir, file_id, ext); + tokio::fs::write(&path, &data) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + path + }; saved_files.push((file_path, file_type.to_string())); } @@ -2229,10 +2295,7 @@ async fn testimonial_add( .await .map_err(|e| cot::Error::internal(e.to_string()))?; let file_id = uuid::Uuid::new_v4(); - let path = format!("{}/{}.{}", upload_dir, file_id, ext); - tokio::fs::write(&path, &data) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + let path = save_uploaded_image(upload_dir, file_id, &ext, &data).await?; image_path = Some(path); } _ => {} @@ -2380,10 +2443,7 @@ async fn testimonial_edit( .await .map_err(|e| cot::Error::internal(e.to_string()))?; let file_id = uuid::Uuid::new_v4(); - let path = format!("{}/{}.{}", upload_dir, file_id, ext); - tokio::fs::write(&path, &data) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + let path = save_uploaded_image(upload_dir, file_id, &ext, &data).await?; new_image_path = Some(path); } _ => {}