From c81f7ce1b7f015afa39d17308e7b7e76010f969c Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Mon, 9 Mar 2026 23:27:09 +0100 Subject: [PATCH] feat(api): relier les settings DB au comportement runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout de DynamicSettings dans AppState (Arc) chargé depuis la DB - rate_limit_per_second, timeout_seconds : plus hardcodés, lus depuis settings - image_processing (format, quality, filter, max_width) : appliqués comme valeurs par défaut sur les requêtes de pages (overridables via query params) - cache.directory : lu depuis settings au lieu de la variable d'env - update_setting recharge immédiatement le DynamicSettings en mémoire pour les clés limits, image_processing et cache (sans redémarrage) - parse_filter() : mapping lanczos3/triangle/nearest → FilterType Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/api_middleware.rs | 3 +- apps/api/src/main.rs | 17 +++++++- apps/api/src/pages.rs | 55 +++++++++++++++--------- apps/api/src/settings.rs | 19 +++++---- apps/api/src/state.rs | 77 +++++++++++++++++++++++++++++++++- apps/api/src/thumbnails.rs | 2 +- 6 files changed, 142 insertions(+), 31 deletions(-) diff --git a/apps/api/src/api_middleware.rs b/apps/api/src/api_middleware.rs index 8972848..dcb1cc8 100644 --- a/apps/api/src/api_middleware.rs +++ b/apps/api/src/api_middleware.rs @@ -28,7 +28,8 @@ pub async fn read_rate_limit( limiter.requests_in_window = 0; } - if limiter.requests_in_window >= 120 { + let rate_limit = state.settings.read().await.rate_limit_per_second; + if limiter.requests_in_window >= rate_limit { return ( axum::http::StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded", diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 26262b7..53c1d57 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -27,10 +27,10 @@ use lru::LruCache; use std::num::NonZeroUsize; use stripstream_core::config::ApiConfig; use sqlx::postgres::PgPoolOptions; -use tokio::sync::{Mutex, Semaphore}; +use tokio::sync::{Mutex, RwLock, Semaphore}; use tracing::info; -use crate::state::{load_concurrent_renders, AppState, Metrics, ReadRateLimit}; +use crate::state::{load_concurrent_renders, load_dynamic_settings, AppState, Metrics, ReadRateLimit}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -50,6 +50,18 @@ async fn main() -> anyhow::Result<()> { let concurrent_renders = load_concurrent_renders(&pool).await; info!("Using concurrent_renders limit: {}", concurrent_renders); + let dynamic_settings = load_dynamic_settings(&pool).await; + info!( + "Dynamic settings: rate_limit={}, timeout={}s, format={}, quality={}, filter={}, max_width={}, cache_dir={}", + dynamic_settings.rate_limit_per_second, + dynamic_settings.timeout_seconds, + dynamic_settings.image_format, + dynamic_settings.image_quality, + dynamic_settings.image_filter, + dynamic_settings.image_max_width, + dynamic_settings.cache_directory, + ); + let state = AppState { pool, bootstrap_token: Arc::from(config.api_bootstrap_token), @@ -62,6 +74,7 @@ async fn main() -> anyhow::Result<()> { window_started_at: Instant::now(), requests_in_window: 0, })), + settings: Arc::new(RwLock::new(dynamic_settings)), }; let admin_routes = Router::new() diff --git a/apps/api/src/pages.rs b/apps/api/src/pages.rs index 8d98e07..3df4310 100644 --- a/apps/api/src/pages.rs +++ b/apps/api/src/pages.rs @@ -31,10 +31,12 @@ fn remap_libraries_path(path: &str) -> String { path.to_string() } -fn get_image_cache_dir() -> PathBuf { - std::env::var("IMAGE_CACHE_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp/stripstream-image-cache")) +fn parse_filter(s: &str) -> image::imageops::FilterType { + match s { + "triangle" => image::imageops::FilterType::Triangle, + "nearest" => image::imageops::FilterType::Nearest, + _ => image::imageops::FilterType::Lanczos3, + } } fn get_cache_key(abs_path: &str, page: u32, format: &str, quality: u8, width: u32) -> String { @@ -47,8 +49,7 @@ fn get_cache_key(abs_path: &str, page: u32, format: &str, quality: u8, width: u3 format!("{:x}", hasher.finalize()) } -fn get_cache_path(cache_key: &str, format: &OutputFormat) -> PathBuf { - let cache_dir = get_image_cache_dir(); +fn get_cache_path(cache_key: &str, format: &OutputFormat, cache_dir: &Path) -> PathBuf { let prefix = &cache_key[..2]; let ext = format.extension(); cache_dir.join(prefix).join(format!("{}.{}", cache_key, ext)) @@ -145,13 +146,21 @@ pub async fn get_page( return Err(ApiError::bad_request("page index starts at 1")); } - let format = OutputFormat::parse(query.format.as_deref())?; - let quality = query.quality.unwrap_or(80).clamp(1, 100); + let (default_format, default_quality, max_width, filter_str, timeout_secs, cache_dir) = { + let s = state.settings.read().await; + (s.image_format.clone(), s.image_quality, s.image_max_width, s.image_filter.clone(), s.timeout_seconds, s.cache_directory.clone()) + }; + + let format_str = query.format.as_deref().unwrap_or(default_format.as_str()); + let format = OutputFormat::parse(Some(format_str))?; + let quality = query.quality.unwrap_or(default_quality).clamp(1, 100); let width = query.width.unwrap_or(0); - if width > 2160 { + if width > max_width { warn!("Invalid width: {}", width); - return Err(ApiError::bad_request("width must be <= 2160")); + return Err(ApiError::bad_request(format!("width must be <= {}", max_width))); } + let filter = parse_filter(&filter_str); + let cache_dir_path = std::path::PathBuf::from(&cache_dir); let memory_cache_key = format!("{book_id}:{n}:{}:{quality}:{width}", format.extension()); @@ -195,7 +204,7 @@ pub async fn get_page( info!("Processing book file: {} (format: {})", abs_path, input_format); let disk_cache_key = get_cache_key(&abs_path, n, format.extension(), quality, width); - let cache_path = get_cache_path(&disk_cache_key, &format); + let cache_path = get_cache_path(&disk_cache_key, &format, &cache_dir_path); if let Some(cached_bytes) = read_from_disk_cache(&cache_path) { info!("Disk cache hit for: {}", cache_path.display()); @@ -221,9 +230,9 @@ pub async fn get_page( let start_time = std::time::Instant::now(); let bytes = tokio::time::timeout( - Duration::from_secs(60), + Duration::from_secs(timeout_secs), tokio::task::spawn_blocking(move || { - render_page(&abs_path_clone, &input_format, n, &format_clone, quality, width) + render_page(&abs_path_clone, &input_format, n, &format_clone, quality, width, filter) }), ) .await @@ -306,9 +315,15 @@ pub async fn render_book_page_1( .await .map_err(|_| ApiError::internal("render limiter unavailable"))?; + let (timeout_secs, filter_str) = { + let s = state.settings.read().await; + (s.timeout_seconds, s.image_filter.clone()) + }; + let filter = parse_filter(&filter_str); + let abs_path_clone = abs_path.clone(); let bytes = tokio::time::timeout( - Duration::from_secs(60), + Duration::from_secs(timeout_secs), tokio::task::spawn_blocking(move || { render_page( &abs_path_clone, @@ -317,6 +332,7 @@ pub async fn render_book_page_1( &OutputFormat::Webp, quality, width, + filter, ) }), ) @@ -334,6 +350,7 @@ fn render_page( out_format: &OutputFormat, quality: u8, width: u32, + filter: image::imageops::FilterType, ) -> Result, ApiError> { let page_bytes = match input_format { "cbz" => extract_cbz_page(abs_path, page_number)?, @@ -342,7 +359,7 @@ fn render_page( _ => return Err(ApiError::bad_request("unsupported source format")), }; - transcode_image(&page_bytes, out_format, quality, width) + transcode_image(&page_bytes, out_format, quality, width, filter) } fn extract_cbz_page(abs_path: &str, page_number: u32) -> Result, ApiError> { @@ -495,12 +512,12 @@ fn render_pdf_page(abs_path: &str, page_number: u32, width: u32) -> Result Result, ApiError> { +fn transcode_image(input: &[u8], out_format: &OutputFormat, quality: u8, width: u32, filter: image::imageops::FilterType) -> Result, ApiError> { debug!("Transcoding image: {} bytes, format: {:?}, quality: {}, width: {}", input.len(), out_format, quality, width); let source_format = image::guess_format(input).ok(); debug!("Source format detected: {:?}", source_format); let needs_transcode = source_format.map(|f| !format_matches(&f, out_format)).unwrap_or(true); - + if width == 0 && !needs_transcode { debug!("No transcoding needed, returning original"); return Ok(input.to_vec()); @@ -511,10 +528,10 @@ fn transcode_image(input: &[u8], out_format: &OutputFormat, quality: u8, width: error!("Failed to load image from memory: {} (input size: {} bytes)", e, input.len()); ApiError::internal(format!("invalid source image: {e}")) })?; - + if width > 0 { debug!("Resizing image to width: {}", width); - image = image.resize(width, u32::MAX, image::imageops::FilterType::Lanczos3); + image = image.resize(width, u32::MAX, filter); } debug!("Converting to RGBA..."); diff --git a/apps/api/src/settings.rs b/apps/api/src/settings.rs index 855d6b2..7fb8dee 100644 --- a/apps/api/src/settings.rs +++ b/apps/api/src/settings.rs @@ -8,7 +8,7 @@ use serde_json::Value; use sqlx::Row; use utoipa::ToSchema; -use crate::{error::ApiError, state::AppState}; +use crate::{error::ApiError, state::{AppState, load_dynamic_settings}}; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateSettingRequest { @@ -134,6 +134,13 @@ pub async fn update_setting( .await?; let value: Value = row.get("value"); + + // Rechargement des settings dynamiques si la clé affecte le comportement runtime + if key == "limits" || key == "image_processing" || key == "cache" { + let new_settings = load_dynamic_settings(&state.pool).await; + *state.settings.write().await = new_settings; + } + Ok(Json(value)) } @@ -148,9 +155,8 @@ pub async fn update_setting( ), security(("Bearer" = [])) )] -pub async fn clear_cache(State(_state): State) -> Result, ApiError> { - let cache_dir = std::env::var("IMAGE_CACHE_DIR") - .unwrap_or_else(|_| "/tmp/stripstream-image-cache".to_string()); +pub async fn clear_cache(State(state): State) -> Result, ApiError> { + let cache_dir = state.settings.read().await.cache_directory.clone(); let result = tokio::task::spawn_blocking(move || { if std::path::Path::new(&cache_dir).exists() { @@ -188,9 +194,8 @@ pub async fn clear_cache(State(_state): State) -> Result) -> Result, ApiError> { - let cache_dir = std::env::var("IMAGE_CACHE_DIR") - .unwrap_or_else(|_| "/tmp/stripstream-image-cache".to_string()); +pub async fn get_cache_stats(State(state): State) -> Result, ApiError> { + let cache_dir = state.settings.read().await.cache_directory.clone(); let cache_dir_clone = cache_dir.clone(); let stats = tokio::task::spawn_blocking(move || { diff --git a/apps/api/src/state.rs b/apps/api/src/state.rs index cd5ca66..2203687 100644 --- a/apps/api/src/state.rs +++ b/apps/api/src/state.rs @@ -6,7 +6,7 @@ use std::time::Instant; use lru::LruCache; use sqlx::{Pool, Postgres, Row}; -use tokio::sync::{Mutex, Semaphore}; +use tokio::sync::{Mutex, RwLock, Semaphore}; #[derive(Clone)] pub struct AppState { @@ -18,6 +18,33 @@ pub struct AppState { pub page_render_limit: Arc, pub metrics: Arc, pub read_rate_limit: Arc>, + pub settings: Arc>, +} + +#[derive(Clone)] +pub struct DynamicSettings { + pub rate_limit_per_second: u32, + pub timeout_seconds: u64, + pub image_format: String, + pub image_quality: u8, + pub image_filter: String, + pub image_max_width: u32, + pub cache_directory: String, +} + +impl Default for DynamicSettings { + fn default() -> Self { + Self { + rate_limit_per_second: 120, + timeout_seconds: 12, + image_format: "webp".to_string(), + image_quality: 85, + image_filter: "lanczos3".to_string(), + image_max_width: 2160, + cache_directory: std::env::var("IMAGE_CACHE_DIR") + .unwrap_or_else(|_| "/tmp/stripstream-image-cache".to_string()), + } + } } pub struct Metrics { @@ -59,3 +86,51 @@ pub async fn load_concurrent_renders(pool: &Pool) -> usize { _ => default_concurrency, } } + +pub async fn load_dynamic_settings(pool: &Pool) -> DynamicSettings { + let mut s = DynamicSettings::default(); + + if let Ok(Some(row)) = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'limits'"#) + .fetch_optional(pool) + .await + { + let v: serde_json::Value = row.get("value"); + if let Some(n) = v.get("rate_limit_per_second").and_then(|x| x.as_u64()) { + s.rate_limit_per_second = n as u32; + } + if let Some(n) = v.get("timeout_seconds").and_then(|x| x.as_u64()) { + s.timeout_seconds = n; + } + } + + if let Ok(Some(row)) = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'image_processing'"#) + .fetch_optional(pool) + .await + { + let v: serde_json::Value = row.get("value"); + if let Some(s2) = v.get("format").and_then(|x| x.as_str()) { + s.image_format = s2.to_string(); + } + if let Some(n) = v.get("quality").and_then(|x| x.as_u64()) { + s.image_quality = n.clamp(1, 100) as u8; + } + if let Some(s2) = v.get("filter").and_then(|x| x.as_str()) { + s.image_filter = s2.to_string(); + } + if let Some(n) = v.get("max_width").and_then(|x| x.as_u64()) { + s.image_max_width = n as u32; + } + } + + if let Ok(Some(row)) = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'cache'"#) + .fetch_optional(pool) + .await + { + let v: serde_json::Value = row.get("value"); + if let Some(dir) = v.get("directory").and_then(|x| x.as_str()) { + s.cache_directory = dir.to_string(); + } + } + + s +} diff --git a/apps/api/src/thumbnails.rs b/apps/api/src/thumbnails.rs index 04aaa14..c05f62f 100644 --- a/apps/api/src/thumbnails.rs +++ b/apps/api/src/thumbnails.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use uuid::Uuid; use utoipa::ToSchema; -use crate::{error::ApiError, index_jobs::{self, IndexJobResponse}, state::AppState}; +use crate::{error::ApiError, index_jobs, state::AppState}; #[derive(Deserialize, ToSchema)] pub struct ThumbnailsRebuildRequest {