diff --git a/src/frontend/index.html b/src/frontend/index.html
index 6c5532a..8f50a05 100644
--- a/src/frontend/index.html
+++ b/src/frontend/index.html
@@ -118,6 +118,49 @@
}
.toast.success { border-left: 3px solid var(--green); }
.toast.error { border-left: 3px solid var(--red); }
+
+ /* ─── Login Overlay ─── */
+ .login-overlay {
+ position: fixed; inset: 0; z-index: 500;
+ background: var(--bg);
+ display: flex; align-items: center; justify-content: center;
+ }
+ .login-box {
+ background: var(--bg-card); border: 1px solid var(--border);
+ border-radius: 12px; padding: 32px;
+ max-width: 420px; width: 90%;
+ box-shadow: var(--shadow);
+ }
+ .login-box h2 { font-size: 22px; margin-bottom: 4px; color: var(--accent); }
+ .login-box .sub { font-size: 13px; color: var(--fg-dim); margin-bottom: 20px; }
+ .login-box label { display: block; font-size: 13px; margin-bottom: 6px; color: var(--fg-dim); }
+ .login-box input[type="password"] {
+ width: 100%; padding: 10px 14px; border-radius: var(--radius);
+ background: var(--bg); border: 1px solid var(--border);
+ color: var(--fg); font-size: 14px; outline: none; margin-bottom: 12px;
+ font-family: monospace;
+ }
+ .login-box input:focus { border-color: var(--accent); }
+ .login-box .login-error { color: var(--red); font-size: 13px; margin-bottom: 10px; display: none; }
+ .login-box .btn-full { width: 100%; justify-content: center; padding: 10px; font-size: 14px; }
+ .login-box .login-hint { font-size: 11px; color: var(--fg-dim); margin-top: 14px; text-align: center; }
+ .login-box .login-hint a { color: var(--accent); }
+
+ /* ─── User Info ─── */
+ .user-info {
+ display: flex; align-items: center; gap: 8px;
+ font-size: 13px; color: var(--fg);
+ background: var(--bg-card); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 5px 12px;
+ white-space: nowrap;
+ }
+ .user-info .user-avatar { font-size: 16px; }
+ .user-info .user-name { font-weight: 600; }
+ .user-info .btn-logout {
+ background: none; border: none; color: var(--fg-dim); cursor: pointer;
+ font-size: 14px; padding: 2px 4px; margin-left: 4px;
+ }
+ .user-info .btn-logout:hover { color: var(--red); }
.toast.info { border-left: 3px solid var(--accent); }
.toast-msg { flex: 1; }
.toast-close {
@@ -243,6 +286,22 @@
+
+
@@ -302,9 +366,20 @@
let currentPage = 1;
let currentQuery = '';
let ws = null;
+let apiKey = null;
+let userName = null;
+
+// ─── Auth Helpers ───
+function getAuthHeaders() {
+ const h = {};
+ if (apiKey) h['X-Api-Key'] = apiKey;
+ return h;
+}
// ─── API Helpers ───
async function api(path, opts = {}) {
+ if (!opts.headers) opts.headers = {};
+ Object.assign(opts.headers, getAuthHeaders());
const r = await fetch(path, opts);
if (!r.ok) {
const body = await r.text();
@@ -338,6 +413,69 @@ function showToast(msg, type = 'info', duration = 4000) {
if (duration > 0) setTimeout(() => { if (toast.parentElement) toast.remove(); }, duration);
}
+// ─── Auth ───
+async function connect() {
+ const input = document.getElementById('api-key-input');
+ const errorEl = document.getElementById('login-error');
+ const btn = document.getElementById('login-btn');
+ const key = input.value.trim();
+
+ if (!key) { errorEl.style.display = 'block'; errorEl.textContent = 'Veuillez entrer une clé API'; return; }
+
+ errorEl.style.display = 'none';
+ btn.disabled = true;
+ btn.textContent = '⏳ Validation…';
+
+ try {
+ const r = await fetch('/api/auth/validate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ api_key: key })
+ });
+ const data = await r.json();
+
+ if (data.valid) {
+ apiKey = key;
+ userName = data.name || 'Utilisateur';
+ localStorage.setItem('imago_api_key', key);
+ localStorage.setItem('imago_user_name', userName);
+ document.getElementById('login-overlay').style.display = 'none';
+ document.getElementById('user-info').style.display = 'flex';
+ document.getElementById('user-name').textContent = userName;
+ showToast(`Connecté en tant que ${userName} ✓`, 'success');
+ initApp();
+ } else {
+ errorEl.style.display = 'block';
+ errorEl.textContent = data.error || 'Clé API invalide';
+ }
+ } catch(e) {
+ errorEl.style.display = 'block';
+ errorEl.textContent = `Erreur : ${e.message}`;
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '🔗 Se connecter';
+ }
+}
+
+function logout() {
+ if (ws) { ws.close(); ws = null; }
+ localStorage.removeItem('imago_api_key');
+ localStorage.removeItem('imago_user_name');
+ apiKey = null;
+ userName = null;
+ document.getElementById('login-overlay').style.display = 'flex';
+ document.getElementById('user-info').style.display = 'none';
+ document.getElementById('api-key-input').value = '';
+ document.getElementById('gallery').innerHTML = '';
+ document.getElementById('pagination').innerHTML = '';
+ document.getElementById('stat-images').textContent = '…';
+ document.getElementById('stat-storage').textContent = '…';
+ document.getElementById('stat-tags').textContent = '…';
+ document.getElementById('stat-uptime').textContent = '●';
+ document.getElementById('stat-uptime').style.color = 'var(--fg-dim)';
+ showToast('Déconnecté', 'info', 2000);
+}
+
// ─── Upload ───
async function uploadFiles(files) {
if (!files || files.length === 0) return;
@@ -359,7 +497,7 @@ async function uploadFiles(files) {
const form = new FormData();
form.append('file', file);
- const r = await fetch('/api/upload', { method: 'POST', body: form });
+ const r = await fetch('/api/upload', { method: 'POST', body: form, headers: getAuthHeaders() });
const data = await r.json();
if (r.ok) {
@@ -613,9 +751,26 @@ function escHtml(s) {
}
// ─── Init ───
-loadImages();
-loadStats();
-connectWS();
+function initApp() {
+ loadImages();
+ loadStats();
+ connectWS();
+}
+
+// Check localStorage for existing session
+(function checkAuth() {
+ const savedKey = localStorage.getItem('imago_api_key');
+ const savedName = localStorage.getItem('imago_user_name');
+ if (savedKey) {
+ apiKey = savedKey;
+ userName = savedName || 'Utilisateur';
+ document.getElementById('login-overlay').style.display = 'none';
+ document.getElementById('user-info').style.display = 'flex';
+ document.getElementById('user-name').textContent = userName;
+ initApp();
+ }
+})();
+
setInterval(loadStats, 30000);
// Keyboard shortcut: Ctrl+U to focus drop zone
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 7b76dea..0443276 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -35,6 +35,8 @@ pub async fn serve_with_config(host: &str, port: u16, config: &Config) -> anyhow
// État partagé
let state = routes::AppState {
imago,
+ base_url: config.imago.url.clone(),
+ api_key: config.imago.api_key.clone(),
ws_hub: ws_hub.clone(),
};
diff --git a/src/server/routes.rs b/src/server/routes.rs
index d478692..0b5a9d7 100644
--- a/src/server/routes.rs
+++ b/src/server/routes.rs
@@ -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
,
+ 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 {
+ 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) {
+ (
+ 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,
}
+/// 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,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+}
+
/// 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>,
+ Json(body): Json,
+) -> 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>,
+ headers: HeaderMap,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut data: Vec = 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>,
+ headers: HeaderMap,
Query(pagination): Query,
) -> 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 = 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>,
+ headers: HeaderMap,
Query(params): Query,
) -> 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 = 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 = 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>,
+ headers: HeaderMap,
Path(id): Path,
) -> 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>,
+ headers: HeaderMap,
Path(id): Path,
Query(params): Query>,
) -> 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>,
+ 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)