- Ajout de DynamicSettings dans AppState (Arc<RwLock>) 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 <noreply@anthropic.com>
327 lines
9.5 KiB
Rust
327 lines
9.5 KiB
Rust
use axum::{
|
|
extract::State,
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use sqlx::Row;
|
|
use utoipa::ToSchema;
|
|
|
|
use crate::{error::ApiError, state::{AppState, load_dynamic_settings}};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct UpdateSettingRequest {
|
|
pub value: Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct ClearCacheResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct CacheStats {
|
|
pub total_size_mb: f64,
|
|
pub file_count: u64,
|
|
pub directory: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct ThumbnailStats {
|
|
pub total_size_mb: f64,
|
|
pub file_count: u64,
|
|
pub directory: String,
|
|
}
|
|
|
|
pub fn settings_routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/settings", get(get_settings))
|
|
.route("/settings/:key", get(get_setting).post(update_setting))
|
|
.route("/settings/cache/clear", post(clear_cache))
|
|
.route("/settings/cache/stats", get(get_cache_stats))
|
|
.route("/settings/thumbnail/stats", get(get_thumbnail_stats))
|
|
}
|
|
|
|
/// List all settings
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/settings",
|
|
tag = "settings",
|
|
responses(
|
|
(status = 200, description = "All settings as key/value object"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn get_settings(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
let rows = sqlx::query(r#"SELECT key, value FROM app_settings"#)
|
|
.fetch_all(&state.pool)
|
|
.await?;
|
|
|
|
let mut settings = serde_json::Map::new();
|
|
for row in rows {
|
|
let key: String = row.get("key");
|
|
let value: Value = row.get("value");
|
|
settings.insert(key, value);
|
|
}
|
|
|
|
Ok(Json(Value::Object(settings)))
|
|
}
|
|
|
|
/// Get a single setting by key
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/settings/{key}",
|
|
tag = "settings",
|
|
params(("key" = String, Path, description = "Setting key")),
|
|
responses(
|
|
(status = 200, description = "Setting value"),
|
|
(status = 404, description = "Setting not found"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn get_setting(
|
|
State(state): State<AppState>,
|
|
axum::extract::Path(key): axum::extract::Path<String>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let row = sqlx::query(r#"SELECT value FROM app_settings WHERE key = $1"#)
|
|
.bind(&key)
|
|
.fetch_optional(&state.pool)
|
|
.await?;
|
|
|
|
match row {
|
|
Some(row) => {
|
|
let value: Value = row.get("value");
|
|
Ok(Json(value))
|
|
}
|
|
None => Err(ApiError::not_found(format!("setting '{}' not found", key))),
|
|
}
|
|
}
|
|
|
|
/// Create or update a setting
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/settings/{key}",
|
|
tag = "settings",
|
|
params(("key" = String, Path, description = "Setting key")),
|
|
request_body = UpdateSettingRequest,
|
|
responses(
|
|
(status = 200, description = "Updated setting value"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn update_setting(
|
|
State(state): State<AppState>,
|
|
axum::extract::Path(key): axum::extract::Path<String>,
|
|
Json(body): Json<UpdateSettingRequest>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let row = sqlx::query(
|
|
r#"
|
|
INSERT INTO app_settings (key, value, updated_at)
|
|
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
|
ON CONFLICT (key)
|
|
DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP
|
|
RETURNING value
|
|
"#,
|
|
)
|
|
.bind(&key)
|
|
.bind(&body.value)
|
|
.fetch_one(&state.pool)
|
|
.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))
|
|
}
|
|
|
|
/// Clear the image page cache
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/settings/cache/clear",
|
|
tag = "settings",
|
|
responses(
|
|
(status = 200, body = ClearCacheResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn clear_cache(State(state): State<AppState>) -> Result<Json<ClearCacheResponse>, 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() {
|
|
match std::fs::remove_dir_all(&cache_dir) {
|
|
Ok(_) => ClearCacheResponse {
|
|
success: true,
|
|
message: format!("Cache directory '{}' cleared successfully", cache_dir),
|
|
},
|
|
Err(e) => ClearCacheResponse {
|
|
success: false,
|
|
message: format!("Failed to clear cache: {}", e),
|
|
},
|
|
}
|
|
} else {
|
|
ClearCacheResponse {
|
|
success: true,
|
|
message: format!("Cache directory '{}' does not exist, nothing to clear", cache_dir),
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("cache clear failed: {}", e)))?;
|
|
|
|
Ok(Json(result))
|
|
}
|
|
|
|
/// Get image page cache statistics
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/settings/cache/stats",
|
|
tag = "settings",
|
|
responses(
|
|
(status = 200, body = CacheStats),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn get_cache_stats(State(state): State<AppState>) -> Result<Json<CacheStats>, 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 || {
|
|
let path = std::path::Path::new(&cache_dir_clone);
|
|
if !path.exists() {
|
|
return CacheStats {
|
|
total_size_mb: 0.0,
|
|
file_count: 0,
|
|
directory: cache_dir_clone,
|
|
};
|
|
}
|
|
|
|
let mut total_size: u64 = 0;
|
|
let mut file_count: u64 = 0;
|
|
|
|
fn visit_dirs(
|
|
dir: &std::path::Path,
|
|
total_size: &mut u64,
|
|
file_count: &mut u64,
|
|
) -> std::io::Result<()> {
|
|
if dir.is_dir() {
|
|
for entry in std::fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
visit_dirs(&path, total_size, file_count)?;
|
|
} else {
|
|
*total_size += entry.metadata()?.len();
|
|
*file_count += 1;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
let _ = visit_dirs(path, &mut total_size, &mut file_count);
|
|
|
|
CacheStats {
|
|
total_size_mb: total_size as f64 / 1024.0 / 1024.0,
|
|
file_count,
|
|
directory: cache_dir_clone,
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("cache stats failed: {}", e)))?;
|
|
|
|
Ok(Json(stats))
|
|
}
|
|
|
|
fn compute_dir_stats(path: &std::path::Path) -> (u64, u64) {
|
|
let mut total_size: u64 = 0;
|
|
let mut file_count: u64 = 0;
|
|
|
|
fn visit_dirs(
|
|
dir: &std::path::Path,
|
|
total_size: &mut u64,
|
|
file_count: &mut u64,
|
|
) -> std::io::Result<()> {
|
|
if dir.is_dir() {
|
|
for entry in std::fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
visit_dirs(&path, total_size, file_count)?;
|
|
} else {
|
|
*total_size += entry.metadata()?.len();
|
|
*file_count += 1;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
let _ = visit_dirs(path, &mut total_size, &mut file_count);
|
|
(total_size, file_count)
|
|
}
|
|
|
|
/// Get thumbnail storage statistics
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/settings/thumbnail/stats",
|
|
tag = "settings",
|
|
responses(
|
|
(status = 200, body = ThumbnailStats),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<ThumbnailStats>, ApiError> {
|
|
let settings = sqlx::query(r#"SELECT value FROM app_settings WHERE key = 'thumbnail'"#)
|
|
.fetch_optional(&_state.pool)
|
|
.await?;
|
|
|
|
let directory = match settings {
|
|
Some(row) => {
|
|
let value: serde_json::Value = row.get("value");
|
|
value.get("directory")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("/data/thumbnails")
|
|
.to_string()
|
|
}
|
|
None => "/data/thumbnails".to_string(),
|
|
};
|
|
|
|
let directory_clone = directory.clone();
|
|
let stats = tokio::task::spawn_blocking(move || {
|
|
let path = std::path::Path::new(&directory_clone);
|
|
if !path.exists() {
|
|
return ThumbnailStats {
|
|
total_size_mb: 0.0,
|
|
file_count: 0,
|
|
directory: directory_clone,
|
|
};
|
|
}
|
|
|
|
let (total_size, file_count) = compute_dir_stats(path);
|
|
|
|
ThumbnailStats {
|
|
total_size_mb: total_size as f64 / 1024.0 / 1024.0,
|
|
file_count,
|
|
directory: directory_clone,
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("thumbnail stats failed: {}", e)))?;
|
|
|
|
Ok(Json(stats))
|
|
}
|