feat: login écran + clé API utilisateur + affichage nom connecté
- Route POST /api/auth/validate (backend) : valide la clé contre Imago
- AppState supporte clé dynamique (X-Api-Key header utilisateur)
- Écran de login overlay avec champ clé API + validation
- Nom d'utilisateur affiché dans le header (👤 bruno) avec bouton logout
- Stockage clé + nom dans localStorage (persiste entre sessions)
- Tous les appels API incluent X-Api-Key du frontend
This commit is contained in:
+159
-9
@@ -3,19 +3,41 @@
|
||||
use axum::{
|
||||
Router, routing::{get, post},
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use crate::imago_client::{ImagoClient, ImageSummary};
|
||||
|
||||
/// État partagé de l'application
|
||||
pub struct AppState {
|
||||
pub imago: Arc<ImagoClient>,
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub ws_hub: crate::server::ws::WsHub,
|
||||
}
|
||||
|
||||
/// Crée un ImagoClient — utilise la clé fournie par l'utilisateur si présente,
|
||||
/// sinon la clé configurée par défaut (admin).
|
||||
fn get_client(state: &AppState, headers: &HeaderMap) -> anyhow::Result<ImagoClient> {
|
||||
let key = headers
|
||||
.get("x-api-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|k| !k.is_empty())
|
||||
.unwrap_or(&state.api_key);
|
||||
ImagoClient::new(state.base_url.clone(), key.to_string())
|
||||
}
|
||||
|
||||
/// Helper pour retourner une erreur JSON
|
||||
fn error_response(status: u16, msg: &str) -> (axum::http::StatusCode, Json<serde_json::Value>) {
|
||||
(
|
||||
axum::http::StatusCode::from_u16(status).unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Json(serde_json::json!({"error": msg})),
|
||||
)
|
||||
}
|
||||
|
||||
/// Paramètres de pagination
|
||||
#[derive(Deserialize)]
|
||||
pub struct Pagination {
|
||||
@@ -31,6 +53,22 @@ pub struct SearchQuery {
|
||||
pub per_page: Option<usize>,
|
||||
}
|
||||
|
||||
/// Requête de validation de clé API
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// Réponse de validation
|
||||
#[derive(Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub valid: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Convertit ImageSummary en JSON frontend-friendly
|
||||
fn image_to_json(img: &ImageSummary) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
@@ -55,6 +93,7 @@ pub fn api_routes(state: AppState) -> Router {
|
||||
let state = Arc::new(state);
|
||||
Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/auth/validate", post(validate_api_key))
|
||||
.route("/api/upload", post(upload_image))
|
||||
.route("/api/images", get(list_images))
|
||||
.route("/api/images/search", get(search_images))
|
||||
@@ -70,9 +109,56 @@ async fn health_check() -> &'static str {
|
||||
"OK"
|
||||
}
|
||||
|
||||
/// Valide une clé API utilisateur contre l'API Imago.
|
||||
/// Si valide, retourne le nom du client (trouvé via les images existantes).
|
||||
async fn validate_api_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<AuthRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let client = match ImagoClient::new(state.base_url.clone(), body.api_key.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Json(serde_json::json!({
|
||||
"valid": false,
|
||||
"error": format!("Erreur client : {}", e)
|
||||
})),
|
||||
};
|
||||
|
||||
match client.list_images(1, 1).await {
|
||||
Ok(response) => {
|
||||
// Récupère le client_name de la première image si elle existe
|
||||
let name = response.items.first()
|
||||
.and_then(|img| img.client_name.clone());
|
||||
Json(serde_json::json!({
|
||||
"valid": true,
|
||||
"name": name
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("{:#}", e);
|
||||
let is_auth_error = err_msg.to_lowercase().contains("401")
|
||||
|| err_msg.to_lowercase().contains("403")
|
||||
|| err_msg.to_lowercase().contains("unauthorized");
|
||||
|
||||
if is_auth_error {
|
||||
Json(serde_json::json!({
|
||||
"valid": false,
|
||||
"error": "Clé API invalide"
|
||||
}))
|
||||
} else {
|
||||
Json(serde_json::json!({
|
||||
"valid": true,
|
||||
"name": null,
|
||||
"warning": format!("Clé acceptée mais l'API est inaccessible : {}", err_msg)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload d'image : reçoit un fichier et le transmet à l'API Imago
|
||||
async fn upload_image(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let mut data: Vec<u8> = Vec::new();
|
||||
@@ -109,7 +195,12 @@ async fn upload_image(
|
||||
);
|
||||
}
|
||||
|
||||
match state.imago.upload_image(data, &filename, None).await {
|
||||
let client = match get_client(&state, &headers) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return error_response(500, &format!("Erreur client : {}", e)),
|
||||
};
|
||||
|
||||
match client.upload_image(data, &filename, None).await {
|
||||
Ok(response) => (
|
||||
axum::http::StatusCode::CREATED,
|
||||
Json(serde_json::json!({
|
||||
@@ -131,12 +222,24 @@ async fn upload_image(
|
||||
|
||||
async fn list_images(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> impl IntoResponse {
|
||||
let page = pagination.page.unwrap_or(1).max(1);
|
||||
let per_page = pagination.per_page.unwrap_or(24).min(100);
|
||||
|
||||
match state.imago.list_images(page, per_page).await {
|
||||
let client = match get_client(&state, &headers) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Json(serde_json::json!({
|
||||
"images": [],
|
||||
"total": 0,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"error": format!("Erreur client : {}", e)
|
||||
})),
|
||||
};
|
||||
|
||||
match client.list_images(page, per_page).await {
|
||||
Ok(response) => {
|
||||
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
||||
Json(serde_json::json!({
|
||||
@@ -161,14 +264,26 @@ async fn list_images(
|
||||
|
||||
async fn search_images(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<SearchQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let query = params.q.unwrap_or_default();
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
let per_page = params.per_page.unwrap_or(24).min(100);
|
||||
|
||||
let client = match get_client(&state, &headers) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Json(serde_json::json!({
|
||||
"images": [],
|
||||
"total": 0,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"error": format!("Erreur client : {}", e)
|
||||
})),
|
||||
};
|
||||
|
||||
if query.is_empty() {
|
||||
match state.imago.list_images(page, per_page).await {
|
||||
match client.list_images(page, per_page).await {
|
||||
Ok(response) => {
|
||||
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
||||
return Json(serde_json::json!({
|
||||
@@ -191,7 +306,7 @@ async fn search_images(
|
||||
}
|
||||
}
|
||||
|
||||
match state.imago.search(&query, per_page, page).await {
|
||||
match client.search(&query, per_page, page).await {
|
||||
Ok(response) => {
|
||||
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
||||
Json(serde_json::json!({
|
||||
@@ -218,6 +333,7 @@ async fn search_images(
|
||||
|
||||
async fn get_image(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let img_id: i64 = match id.parse() {
|
||||
@@ -229,7 +345,14 @@ async fn get_image(
|
||||
}
|
||||
};
|
||||
|
||||
match state.imago.get_image(img_id).await {
|
||||
let client = match get_client(&state, &headers) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Json(serde_json::json!({
|
||||
"error": format!("Erreur client : {}", e), "id": id
|
||||
})),
|
||||
};
|
||||
|
||||
match client.get_image(img_id).await {
|
||||
Ok(detail) => {
|
||||
Json(serde_json::json!({
|
||||
"id": detail.id,
|
||||
@@ -268,6 +391,7 @@ async fn get_image(
|
||||
|
||||
async fn get_thumbnail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
@@ -283,10 +407,20 @@ async fn get_thumbnail(
|
||||
|
||||
let full = params.get("full").map(|v| v == "1").unwrap_or(false);
|
||||
|
||||
let client = match get_client(&state, &headers) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return axum::http::Response::builder()
|
||||
.status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "text/plain")
|
||||
.body(axum::body::Body::from(format!("Erreur client : {}", e))).unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
let result = if full {
|
||||
state.imago.get_file(img_id).await
|
||||
client.get_file(img_id).await
|
||||
} else {
|
||||
state.imago.get_thumbnail(img_id).await
|
||||
client.get_thumbnail(img_id).await
|
||||
};
|
||||
|
||||
match result {
|
||||
@@ -308,8 +442,24 @@ async fn get_thumbnail(
|
||||
|
||||
async fn stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let (total_images, storage_mb, unique_tags) = match state.imago.list_images(1, 1).await {
|
||||
let client = match get_client(&state, &headers) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Json(serde_json::json!({
|
||||
"total_images": 0,
|
||||
"total_storage_bytes": 0,
|
||||
"unique_tags": 0,
|
||||
"recent_uploads": 0,
|
||||
"ws_clients": state.ws_hub.client_count(),
|
||||
"status": "error",
|
||||
"error": "Erreur client API"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let (total_images, storage_mb, unique_tags) = match client.list_images(1, 1).await {
|
||||
Ok(response) => {
|
||||
let tags = if !response.items.is_empty() {
|
||||
response.items[0].ai_tags.as_ref().map(|t| t.len()).unwrap_or(0)
|
||||
|
||||
Reference in New Issue
Block a user