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
-4
@@ -118,6 +118,49 @@
|
|||||||
}
|
}
|
||||||
.toast.success { border-left: 3px solid var(--green); }
|
.toast.success { border-left: 3px solid var(--green); }
|
||||||
.toast.error { border-left: 3px solid var(--red); }
|
.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.info { border-left: 3px solid var(--accent); }
|
||||||
.toast-msg { flex: 1; }
|
.toast-msg { flex: 1; }
|
||||||
.toast-close {
|
.toast-close {
|
||||||
@@ -243,6 +286,22 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- ─── Login Overlay ─── -->
|
||||||
|
<div class="login-overlay" id="login-overlay">
|
||||||
|
<div class="login-box">
|
||||||
|
<h2>🔐 Connexion Imago</h2>
|
||||||
|
<div class="sub">Entrez votre clé API pour accéder au dashboard</div>
|
||||||
|
<label for="api-key-input">Clé API</label>
|
||||||
|
<input type="password" id="api-key-input" placeholder="Pastez votre clé API Imago…" autocomplete="off" onkeydown="if(event.key==='Enter')connect()">
|
||||||
|
<div class="login-error" id="login-error"></div>
|
||||||
|
<button class="btn accent btn-full" id="login-btn" onclick="connect()">🔗 Se connecter</button>
|
||||||
|
<div class="login-hint">
|
||||||
|
Créez une clé dans <a href="http://dev.lab.home:3001" target="_blank">l'admin Imago</a>
|
||||||
|
(scopes requis : images:read, images:write)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ─── Header ─── -->
|
<!-- ─── Header ─── -->
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">🖼️ Imago<span>Dashboard</span></div>
|
<div class="logo">🖼️ Imago<span>Dashboard</span></div>
|
||||||
@@ -250,6 +309,11 @@
|
|||||||
<span class="ws-dot off" id="ws-dot"></span>
|
<span class="ws-dot off" id="ws-dot"></span>
|
||||||
<span id="ws-label">Déconnecté</span>
|
<span id="ws-label">Déconnecté</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="user-info" id="user-info" style="display:none">
|
||||||
|
<span class="user-avatar">👤</span>
|
||||||
|
<span class="user-name" id="user-name"></span>
|
||||||
|
<button class="btn-logout" onclick="logout()" title="Déconnexion">⏻</button>
|
||||||
|
</div>
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<input type="text" id="search" placeholder="Rechercher par texte, tag, description…" autocomplete="off" enterkeyhint="search">
|
<input type="text" id="search" placeholder="Rechercher par texte, tag, description…" autocomplete="off" enterkeyhint="search">
|
||||||
<button class="btn" id="search-btn">🔍</button>
|
<button class="btn" id="search-btn">🔍</button>
|
||||||
@@ -302,9 +366,20 @@
|
|||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let currentQuery = '';
|
let currentQuery = '';
|
||||||
let ws = null;
|
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 ───
|
// ─── API Helpers ───
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
|
if (!opts.headers) opts.headers = {};
|
||||||
|
Object.assign(opts.headers, getAuthHeaders());
|
||||||
const r = await fetch(path, opts);
|
const r = await fetch(path, opts);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const body = await r.text();
|
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);
|
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 ───
|
// ─── Upload ───
|
||||||
async function uploadFiles(files) {
|
async function uploadFiles(files) {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
@@ -359,7 +497,7 @@ async function uploadFiles(files) {
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
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();
|
const data = await r.json();
|
||||||
|
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
@@ -613,9 +751,26 @@ function escHtml(s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Init ───
|
// ─── Init ───
|
||||||
loadImages();
|
function initApp() {
|
||||||
loadStats();
|
loadImages();
|
||||||
connectWS();
|
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);
|
setInterval(loadStats, 30000);
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+U to focus drop zone
|
// Keyboard shortcut: Ctrl+U to focus drop zone
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ pub async fn serve_with_config(host: &str, port: u16, config: &Config) -> anyhow
|
|||||||
// État partagé
|
// État partagé
|
||||||
let state = routes::AppState {
|
let state = routes::AppState {
|
||||||
imago,
|
imago,
|
||||||
|
base_url: config.imago.url.clone(),
|
||||||
|
api_key: config.imago.api_key.clone(),
|
||||||
ws_hub: ws_hub.clone(),
|
ws_hub: ws_hub.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+159
-9
@@ -3,19 +3,41 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router, routing::{get, post},
|
Router, routing::{get, post},
|
||||||
extract::{Multipart, Path, Query, State},
|
extract::{Multipart, Path, Query, State},
|
||||||
|
http::HeaderMap,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use crate::imago_client::{ImagoClient, ImageSummary};
|
use crate::imago_client::{ImagoClient, ImageSummary};
|
||||||
|
|
||||||
/// État partagé de l'application
|
/// État partagé de l'application
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub imago: Arc<ImagoClient>,
|
pub imago: Arc<ImagoClient>,
|
||||||
|
pub base_url: String,
|
||||||
|
pub api_key: String,
|
||||||
pub ws_hub: crate::server::ws::WsHub,
|
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
|
/// Paramètres de pagination
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct Pagination {
|
pub struct Pagination {
|
||||||
@@ -31,6 +53,22 @@ pub struct SearchQuery {
|
|||||||
pub per_page: Option<usize>,
|
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
|
/// Convertit ImageSummary en JSON frontend-friendly
|
||||||
fn image_to_json(img: &ImageSummary) -> serde_json::Value {
|
fn image_to_json(img: &ImageSummary) -> serde_json::Value {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -55,6 +93,7 @@ pub fn api_routes(state: AppState) -> Router {
|
|||||||
let state = Arc::new(state);
|
let state = Arc::new(state);
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/health", get(health_check))
|
.route("/api/health", get(health_check))
|
||||||
|
.route("/api/auth/validate", post(validate_api_key))
|
||||||
.route("/api/upload", post(upload_image))
|
.route("/api/upload", post(upload_image))
|
||||||
.route("/api/images", get(list_images))
|
.route("/api/images", get(list_images))
|
||||||
.route("/api/images/search", get(search_images))
|
.route("/api/images/search", get(search_images))
|
||||||
@@ -70,9 +109,56 @@ async fn health_check() -> &'static str {
|
|||||||
"OK"
|
"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
|
/// Upload d'image : reçoit un fichier et le transmet à l'API Imago
|
||||||
async fn upload_image(
|
async fn upload_image(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let mut data: Vec<u8> = Vec::new();
|
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) => (
|
Ok(response) => (
|
||||||
axum::http::StatusCode::CREATED,
|
axum::http::StatusCode::CREATED,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
@@ -131,12 +222,24 @@ async fn upload_image(
|
|||||||
|
|
||||||
async fn list_images(
|
async fn list_images(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
Query(pagination): Query<Pagination>,
|
Query(pagination): Query<Pagination>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let page = pagination.page.unwrap_or(1).max(1);
|
let page = pagination.page.unwrap_or(1).max(1);
|
||||||
let per_page = pagination.per_page.unwrap_or(24).min(100);
|
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) => {
|
Ok(response) => {
|
||||||
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
@@ -161,14 +264,26 @@ async fn list_images(
|
|||||||
|
|
||||||
async fn search_images(
|
async fn search_images(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
Query(params): Query<SearchQuery>,
|
Query(params): Query<SearchQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let query = params.q.unwrap_or_default();
|
let query = params.q.unwrap_or_default();
|
||||||
let page = params.page.unwrap_or(1).max(1);
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
let per_page = params.per_page.unwrap_or(24).min(100);
|
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() {
|
if query.is_empty() {
|
||||||
match state.imago.list_images(page, per_page).await {
|
match client.list_images(page, per_page).await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
||||||
return Json(serde_json::json!({
|
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) => {
|
Ok(response) => {
|
||||||
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
let images: Vec<serde_json::Value> = response.items.iter().map(image_to_json).collect();
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
@@ -218,6 +333,7 @@ async fn search_images(
|
|||||||
|
|
||||||
async fn get_image(
|
async fn get_image(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let img_id: i64 = match id.parse() {
|
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) => {
|
Ok(detail) => {
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"id": detail.id,
|
"id": detail.id,
|
||||||
@@ -268,6 +391,7 @@ async fn get_image(
|
|||||||
|
|
||||||
async fn get_thumbnail(
|
async fn get_thumbnail(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
@@ -283,10 +407,20 @@ async fn get_thumbnail(
|
|||||||
|
|
||||||
let full = params.get("full").map(|v| v == "1").unwrap_or(false);
|
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 {
|
let result = if full {
|
||||||
state.imago.get_file(img_id).await
|
client.get_file(img_id).await
|
||||||
} else {
|
} else {
|
||||||
state.imago.get_thumbnail(img_id).await
|
client.get_thumbnail(img_id).await
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -308,8 +442,24 @@ async fn get_thumbnail(
|
|||||||
|
|
||||||
async fn stats(
|
async fn stats(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> 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) => {
|
Ok(response) => {
|
||||||
let tags = if !response.items.is_empty() {
|
let tags = if !response.items.is_empty() {
|
||||||
response.items[0].ai_tags.as_ref().map(|t| t.len()).unwrap_or(0)
|
response.items[0].ai_tags.as_ref().map(|t| t.len()).unwrap_or(0)
|
||||||
|
|||||||
Reference in New Issue
Block a user