问题: - SystemStatsResponse 在 bin/proxy.rs 中定义 - 库模块 proxy.rs 和 validate.rs 试图从 super 导入,但路径错误 修复: - 将 SystemStatsResponse 定义移到库模块 proxy.rs - 在 validate.rs 中添加正确的导入 - 移除 bin/proxy.rs 中的重复定义(稍后处理)
1448 lines
58 KiB
Rust
1448 lines
58 KiB
Rust
use axum::{
|
|
body::Body,
|
|
extract::{State, Query},
|
|
http::{HeaderValue, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
Router,
|
|
};
|
|
use tower_http::cors::{Any, CorsLayer};
|
|
use crate::db;
|
|
|
|
#[path = "validate.rs"]
|
|
mod validate;
|
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|
use image::GenericImageView;
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::num::NonZeroUsize;
|
|
use lru::LruCache;
|
|
use tokio::sync::Semaphore;
|
|
|
|
fn get_api_key() -> String {
|
|
std::env::var("MOMENTRY_API_KEY")
|
|
.expect("MOMENTRY_API_KEY environment variable must be set")
|
|
}
|
|
|
|
pub fn get_output_dir() -> std::path::PathBuf {
|
|
std::env::var_os("MOMENTRY_OUTPUT_DIR")
|
|
.map(std::path::PathBuf::from)
|
|
.or_else(|| {
|
|
std::env::current_exe()
|
|
.ok()
|
|
.and_then(|p| p.parent().map(|d| d.join("output")))
|
|
})
|
|
.unwrap_or_else(|| std::path::PathBuf::from("output"))
|
|
}
|
|
|
|
fn get_data_dir() -> std::path::PathBuf {
|
|
std::env::var_os("MOMENTRY_DATA_DIR")
|
|
.map(std::path::PathBuf::from)
|
|
.or_else(|| {
|
|
std::env::current_exe()
|
|
.ok()
|
|
.and_then(|p| p.parent().map(|d| d.join("data")))
|
|
})
|
|
.unwrap_or_else(|| std::path::PathBuf::from("data"))
|
|
}
|
|
|
|
fn get_admin_config_path() -> std::path::PathBuf {
|
|
std::env::var_os("MOMENTRY_ADMIN_CONFIG")
|
|
.map(std::path::PathBuf::from)
|
|
.or_else(|| {
|
|
std::env::current_exe()
|
|
.ok()
|
|
.and_then(|p| p.parent().map(|d| d.join("config/admin_config.json")))
|
|
})
|
|
.unwrap_or_else(|| std::path::PathBuf::from("config/admin_config.json"))
|
|
}
|
|
|
|
const CORE_API: &str = "http://127.0.0.1:3002";
|
|
const MARKBASE_API: &str = "http://127.0.0.1:11438";
|
|
const DIST_DIR: &str = "../dist";
|
|
|
|
static FACE_THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
|
static PROFILE_PROXY_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
|
static THUMB_SEM_MAP_PROXY: Mutex<Option<HashMap<String, Arc<Semaphore>>>> = Mutex::new(None);
|
|
|
|
fn acquire_thumb_semaphore(uuid: &str) -> Arc<Semaphore> {
|
|
let mut map = THUMB_SEM_MAP_PROXY.lock().unwrap();
|
|
let map = map.get_or_insert_with(HashMap::new);
|
|
map.entry(uuid.to_string())
|
|
.or_insert_with(|| Arc::new(Semaphore::new(1)))
|
|
.clone()
|
|
}
|
|
|
|
fn get_face_thumb_cache() -> std::sync::MutexGuard<'static, Option<LruCache<String, String>>> {
|
|
let mut guard = FACE_THUMB_CACHE.lock().unwrap();
|
|
if guard.is_none() {
|
|
*guard = Some(LruCache::new(NonZeroUsize::new(500).unwrap()));
|
|
}
|
|
guard
|
|
}
|
|
|
|
fn get_profile_proxy_cache() -> std::sync::MutexGuard<'static, Option<LruCache<String, String>>> {
|
|
let mut guard = PROFILE_PROXY_CACHE.lock().unwrap();
|
|
if guard.is_none() {
|
|
*guard = Some(LruCache::new(NonZeroUsize::new(300).unwrap()));
|
|
}
|
|
guard
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ProxyState {
|
|
pub client: reqwest::Client,
|
|
pub serve_static: bool,
|
|
}
|
|
|
|
pub async fn start_proxy_server() {
|
|
let serve_static = std::env::var("SERVE_STATIC").is_ok();
|
|
let state = ProxyState {
|
|
client: reqwest::Client::builder()
|
|
.connect_timeout(std::time::Duration::from_secs(10))
|
|
.timeout(std::time::Duration::from_secs(600))
|
|
.pool_max_idle_per_host(10)
|
|
.build()
|
|
.unwrap(),
|
|
serve_static,
|
|
};
|
|
|
|
let cors = CorsLayer::new()
|
|
.allow_origin(Any)
|
|
.allow_methods(Any)
|
|
.allow_headers(Any);
|
|
|
|
let app = Router::new()
|
|
.route("/health", axum::routing::get(health_handler))
|
|
.route("/api/v1/auth/login", axum::routing::post(login_handler))
|
|
.route("/api/v1/face-thumbnail", axum::routing::get(get_face_thumbnail_handler))
|
|
.route("/api/v1/media/frame", axum::routing::get(get_frame_handler))
|
|
.route("/api/v1/identity-matches", axum::routing::get(get_identity_matches_handler))
|
|
.route("/api/v1/trace-profile-file", axum::routing::get(get_trace_profile_file_handler))
|
|
.route("/api/v1/cluster-results", axum::routing::get(get_cluster_results_handler))
|
|
.route("/api/v1/processor-json", axum::routing::get(get_processor_json_handler))
|
|
.route("/api/v1/file/thumbnail", axum::routing::get(get_file_thumbnail_by_path_handler))
|
|
.route("/api/v1/file/:file_uuid/pose", axum::routing::get(get_pose_handler))
|
|
.route("/api/v1/file/:file_uuid/appearance", axum::routing::get(get_appearance_handler))
|
|
.route("/api/v1/admin/config", axum::routing::get(get_admin_config_handler))
|
|
.route("/api/v1/admin/config", axum::routing::post(save_admin_config_handler))
|
|
.route("/api/v1/system-stats", axum::routing::get(get_system_stats_handler))
|
|
.route("/api/v1/system-stats/validate", axum::routing::get(validate_system_stats_handler))
|
|
.fallback(fallback_handler)
|
|
.layer(cors)
|
|
.with_state(state);
|
|
|
|
let listener = match tokio::net::TcpListener::bind("0.0.0.0:8888").await {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
eprintln!("[proxy] Failed to bind port 8888: {}. Proxy will not be available.", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
eprintln!("[proxy] HTTP server listening on http://0.0.0.0:8888 (serve_static={})", serve_static);
|
|
|
|
if let Err(e) = axum::serve(listener, app).await {
|
|
eprintln!("[proxy] Server error: {}", e);
|
|
}
|
|
}
|
|
|
|
async fn health_handler() -> &'static str {
|
|
"ok"
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct LoginRequest {
|
|
username: String,
|
|
password: String,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct LoginResponse {
|
|
success: bool,
|
|
user: Option<UserInfo>,
|
|
token: Option<String>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct UserInfo {
|
|
id: String,
|
|
username: String,
|
|
display_name: Option<String>,
|
|
role: String,
|
|
}
|
|
|
|
async fn login_handler(body: axum::body::Bytes) -> Response {
|
|
let req: Result<LoginRequest, _> = serde_json::from_slice(&body);
|
|
|
|
match req {
|
|
Ok(login_req) => {
|
|
match db::verify_user(&login_req.username, &login_req.password) {
|
|
Ok(Some(user)) => {
|
|
let token = uuid::Uuid::new_v4().to_string();
|
|
let response = LoginResponse {
|
|
success: true,
|
|
user: Some(UserInfo {
|
|
id: user.id,
|
|
username: user.username,
|
|
display_name: user.display_name,
|
|
role: user.role,
|
|
}),
|
|
token: Some(token),
|
|
error: None,
|
|
};
|
|
(StatusCode::OK, axum::Json(response)).into_response()
|
|
}
|
|
Ok(None) => {
|
|
let response = LoginResponse {
|
|
success: false,
|
|
user: None,
|
|
token: None,
|
|
error: Some("Invalid username or password".to_string()),
|
|
};
|
|
(StatusCode::UNAUTHORIZED, axum::Json(response)).into_response()
|
|
}
|
|
Err(e) => {
|
|
let response = LoginResponse {
|
|
success: false,
|
|
user: None,
|
|
token: None,
|
|
error: Some(e),
|
|
};
|
|
(StatusCode::INTERNAL_SERVER_ERROR, axum::Json(response)).into_response()
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let response = LoginResponse {
|
|
success: false,
|
|
user: None,
|
|
token: None,
|
|
error: Some(format!("Invalid request: {}", e)),
|
|
};
|
|
(StatusCode::BAD_REQUEST, axum::Json(response)).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn fallback_handler(State(state): State<ProxyState>, req: axum::extract::Request) -> Response {
|
|
let path = req.uri().path().to_string();
|
|
let method = req.method().clone();
|
|
|
|
// Handle identity profile GET locally
|
|
if method == axum::http::Method::GET {
|
|
if let Some(rest) = path.strip_prefix("/api/v1/identity/") {
|
|
if rest.ends_with("/profile") {
|
|
let uuid = rest.trim_end_matches("/profile").to_string();
|
|
return get_identity_profile_handler_inner(uuid).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle identity profile-image POST: proxy to Core API + invalidate cache
|
|
if method == axum::http::Method::POST {
|
|
if let Some(rest) = path.strip_prefix("/api/v1/identity/") {
|
|
if rest.ends_with("/profile-image") {
|
|
let uuid = rest.trim_end_matches("/profile-image").to_string();
|
|
let response = proxy_to(state, req, CORE_API, true).await;
|
|
// Invalidate profile cache on successful upload
|
|
let status = response.status();
|
|
if status == StatusCode::OK || status == StatusCode::CREATED {
|
|
let mut cache = get_profile_proxy_cache();
|
|
if let Some(c) = cache.as_mut() { c.pop(&uuid); }
|
|
eprintln!("[proxy] PROFILE cache invalidated for {}", uuid);
|
|
}
|
|
return response;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle search-history CRUD locally
|
|
if path.starts_with("/api/v1/search-history") || path.starts_with("/api/v1/bookmarks") || path.starts_with("/api/v1/video-marks") || path.starts_with("/api/v1/video-mark/") {
|
|
return handle_local_api(req).await;
|
|
}
|
|
|
|
if path.starts_with("/api/v2/") {
|
|
return proxy_to(state, req, MARKBASE_API, false).await;
|
|
} else if path.starts_with("/api/") {
|
|
return proxy_to(state, req, CORE_API, true).await;
|
|
} else if state.serve_static {
|
|
return serve_spa(&path).await;
|
|
} else {
|
|
return (StatusCode::NOT_FOUND, "Not found").into_response();
|
|
}
|
|
}
|
|
|
|
async fn handle_local_api(req: axum::extract::Request) -> Response {
|
|
let path = req.uri().path().to_string();
|
|
let query_str = req.uri().query().unwrap_or("").to_string();
|
|
let method = req.method().clone();
|
|
let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await.unwrap_or_default();
|
|
|
|
if method == axum::http::Method::GET && path == "/api/v1/search-history" {
|
|
return get_search_history_handler_inner();
|
|
}
|
|
if method == axum::http::Method::POST && path == "/api/v1/search-history" {
|
|
return save_search_history_handler_inner(body_bytes);
|
|
}
|
|
if method == axum::http::Method::PATCH {
|
|
if let Some(rest) = path.strip_prefix("/api/v1/search-history/") {
|
|
if let Some(id) = rest.strip_suffix("/rename") {
|
|
return rename_search_history_handler_inner(id.to_string(), body_bytes);
|
|
}
|
|
if let Some(id) = rest.strip_suffix("/pin") {
|
|
return pin_search_history_handler_inner(id.to_string(), body_bytes);
|
|
}
|
|
}
|
|
}
|
|
if method == axum::http::Method::DELETE {
|
|
if let Some(id) = path.strip_prefix("/api/v1/search-history/") {
|
|
return delete_search_history_handler_inner(id.to_string());
|
|
}
|
|
if let Some(id_str) = path.strip_prefix("/api/v1/bookmarks/") {
|
|
if let Ok(id) = id_str.parse::<i64>() {
|
|
return delete_bookmark_handler_inner(id);
|
|
}
|
|
}
|
|
}
|
|
if method == axum::http::Method::GET && path == "/api/v1/bookmarks" {
|
|
return get_bookmarks_handler_inner();
|
|
}
|
|
if method == axum::http::Method::POST && path == "/api/v1/bookmarks" {
|
|
return save_bookmark_handler_inner(body_bytes);
|
|
}
|
|
|
|
// Video marks (local SQLite)
|
|
if method == axum::http::Method::GET && path == "/api/v1/video-marks" {
|
|
return get_video_marks_handler_inner(&query_str);
|
|
}
|
|
if method == axum::http::Method::POST && path == "/api/v1/video-marks" {
|
|
return save_video_mark_handler_inner(body_bytes);
|
|
}
|
|
if method == axum::http::Method::DELETE {
|
|
if let Some(id_str) = path.strip_prefix("/api/v1/video-mark/") {
|
|
if let Ok(id) = id_str.parse::<i64>() {
|
|
return delete_video_mark_handler_inner(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
(StatusCode::NOT_FOUND, "Not found").into_response()
|
|
}
|
|
|
|
async fn proxy_to(state: ProxyState, req: axum::extract::Request, base: &str, inject_api_key: bool) -> Response {
|
|
let path = req.uri().path().to_string();
|
|
let method = req.method().clone();
|
|
let headers = req.headers().clone();
|
|
let start = std::time::Instant::now();
|
|
|
|
let query_str = req.uri().query().unwrap_or("").to_string();
|
|
let mut url = if inject_api_key {
|
|
format!("{}{}?api_key={}", base, path, get_api_key())
|
|
} else {
|
|
format!("{}{}", base, path)
|
|
};
|
|
for pair in query_str.split('&') {
|
|
if pair.is_empty() || (inject_api_key && pair.starts_with("api_key=")) {
|
|
continue;
|
|
}
|
|
url.push_str(&format!("&{}", pair));
|
|
}
|
|
|
|
let target = if base == CORE_API { "core api" } else { "markbase" };
|
|
let range_hdr = headers.get("range").and_then(|v| v.to_str().ok()).unwrap_or("-");
|
|
eprintln!("[proxy] --> {} {} ({}) range={}", method, path, target, range_hdr);
|
|
|
|
let reqwest_method = match method.as_str() {
|
|
"POST" => reqwest::Method::POST,
|
|
"PUT" => reqwest::Method::PUT,
|
|
"PATCH" => reqwest::Method::PATCH,
|
|
"DELETE" => reqwest::Method::DELETE,
|
|
"HEAD" => reqwest::Method::HEAD,
|
|
"OPTIONS" => reqwest::Method::OPTIONS,
|
|
_ => reqwest::Method::GET,
|
|
};
|
|
|
|
let mut req_builder = state.client.request(reqwest_method, &url);
|
|
|
|
for (name, value) in headers.iter() {
|
|
let name_str = name.as_str();
|
|
if name_str == "host" || name_str == "origin" || name_str == "referer" {
|
|
continue;
|
|
}
|
|
if let Ok(v) = String::from_utf8(value.as_bytes().to_vec()) {
|
|
req_builder = req_builder.header(name_str, v);
|
|
}
|
|
}
|
|
|
|
let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await.unwrap_or_default();
|
|
if !body_bytes.is_empty() {
|
|
if method == axum::http::Method::POST && path.contains("/unbind") {
|
|
eprintln!("[proxy] UNBIND body: {}", String::from_utf8_lossy(&body_bytes));
|
|
}
|
|
req_builder = req_builder.body(body_bytes.to_vec());
|
|
}
|
|
|
|
// Check if this is a video request (stream instead of buffer)
|
|
let is_video_request = path.contains("/video") || headers.contains_key("range");
|
|
|
|
match req_builder.send().await {
|
|
Ok(resp) => {
|
|
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
let resp_headers = resp.headers().clone();
|
|
|
|
// Check if response is video content
|
|
let content_type = resp_headers.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
let should_stream = is_video_request
|
|
|| content_type.starts_with("video/")
|
|
|| content_type == "application/octet-stream";
|
|
|
|
if should_stream {
|
|
eprintln!("[proxy] <-- {} {} {} streaming {}ms cl={} ar={} cr={}",
|
|
method, path, status.as_u16(), start.elapsed().as_millis(),
|
|
resp_headers.get("content-length").and_then(|v| v.to_str().ok()).unwrap_or("-"),
|
|
resp_headers.get("accept-ranges").and_then(|v| v.to_str().ok()).unwrap_or("-"),
|
|
resp_headers.get("content-range").and_then(|v| v.to_str().ok()).unwrap_or("-"));
|
|
|
|
let mut builder = axum::http::Response::builder().status(status);
|
|
for (name, value) in resp_headers.iter() {
|
|
let name_str = name.as_str();
|
|
if name_str == "transfer-encoding" || name_str == "content-encoding" {
|
|
continue;
|
|
}
|
|
if let Ok(v) = HeaderValue::from_bytes(value.as_bytes()) {
|
|
builder = builder.header(name_str, v);
|
|
}
|
|
}
|
|
// Stream the body directly
|
|
let stream_body = Body::from_stream(resp.bytes_stream());
|
|
return match builder.body(stream_body) {
|
|
Ok(r) => r.into_response(),
|
|
Err(e) => {
|
|
eprintln!("[proxy] Error building streaming response: {}", e);
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "Proxy error").into_response()
|
|
}
|
|
};
|
|
}
|
|
|
|
// Non-video: buffer entire response
|
|
let resp_bytes = match resp.bytes().await {
|
|
Ok(b) => b.to_vec(),
|
|
Err(e) => {
|
|
eprintln!("[proxy] Error reading response body: {}", e);
|
|
return (StatusCode::BAD_GATEWAY, "Error reading response").into_response();
|
|
}
|
|
};
|
|
let size = resp_bytes.len();
|
|
if method == axum::http::Method::POST && path.contains("/unbind") {
|
|
eprintln!("[proxy] UNBIND response: {} {}", status.as_u16(), String::from_utf8_lossy(&resp_bytes));
|
|
}
|
|
let mut builder = axum::http::Response::builder().status(status);
|
|
for (name, value) in resp_headers.iter() {
|
|
let name_str = name.as_str();
|
|
if name_str == "transfer-encoding" || name_str == "content-encoding" {
|
|
continue;
|
|
}
|
|
if let Ok(v) = HeaderValue::from_bytes(value.as_bytes()) {
|
|
builder = builder.header(name_str, v);
|
|
}
|
|
}
|
|
let accept_ranges = resp_headers.get("accept-ranges").and_then(|v| v.to_str().ok()).unwrap_or("-");
|
|
let content_range = resp_headers.get("content-range").and_then(|v| v.to_str().ok()).unwrap_or("-");
|
|
let content_length = resp_headers.get("content-length").and_then(|v| v.to_str().ok()).unwrap_or("-");
|
|
eprintln!("[proxy] <-- {} {} {} {} {}ms cl={} ar={} cr={}", method, path, status.as_u16(), size, start.elapsed().as_millis(), content_length, accept_ranges, content_range);
|
|
match builder.body(Body::from(resp_bytes)) {
|
|
Ok(r) => r.into_response(),
|
|
Err(e) => {
|
|
eprintln!("[proxy] Error building response: {}", e);
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "Proxy error").into_response()
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("[proxy] <-- {} {} ERR {}ms: {}", method, path, start.elapsed().as_millis(), e);
|
|
(StatusCode::BAD_GATEWAY, format!("Proxy error: {}", e)).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn serve_spa(path: &str) -> Response {
|
|
let file_path = path.trim_start_matches('/');
|
|
|
|
if file_path.is_empty() || file_path == "index.html" {
|
|
return serve_file(DIST_DIR, "index.html").await;
|
|
}
|
|
|
|
let safe_path = file_path.replace("..", "");
|
|
if tokio::fs::try_exists(format!("{}/{}", DIST_DIR, safe_path)).await.unwrap_or(false) {
|
|
return serve_file(DIST_DIR, &safe_path).await;
|
|
}
|
|
|
|
serve_file(DIST_DIR, "index.html").await
|
|
}
|
|
|
|
fn content_type_for(path: &str) -> &'static str {
|
|
match path.rsplit('.').next() {
|
|
Some("js") => "application/javascript; charset=utf-8",
|
|
Some("css") => "text/css; charset=utf-8",
|
|
Some("html") => "text/html; charset=utf-8",
|
|
Some("json") => "application/json",
|
|
Some("png") => "image/png",
|
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
|
Some("svg") => "image/svg+xml",
|
|
Some("ico") => "image/x-icon",
|
|
Some("woff") => "font/woff",
|
|
Some("woff2") => "font/woff2",
|
|
Some("map") => "application/json",
|
|
_ => "application/octet-stream",
|
|
}
|
|
}
|
|
|
|
async fn serve_file(dir: &str, name: &str) -> Response {
|
|
match tokio::fs::read(format!("{}/{}", dir, name)).await {
|
|
Ok(bytes) => {
|
|
let ct = content_type_for(name);
|
|
(
|
|
StatusCode::OK,
|
|
[(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(ct))],
|
|
bytes,
|
|
)
|
|
.into_response()
|
|
}
|
|
Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(),
|
|
}
|
|
}
|
|
|
|
// ===== Local SQLite API handlers =====
|
|
|
|
fn get_search_history_handler_inner() -> Response {
|
|
match db::get_search_history(Some(30)) {
|
|
Ok(items) => axum::Json(items).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
fn save_search_history_handler_inner(body: axum::body::Bytes) -> Response {
|
|
let req: serde_json::Value = match serde_json::from_slice(&body) {
|
|
Ok(v) => v,
|
|
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(),
|
|
};
|
|
let id = req.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let query = req.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let title = req.get("title").and_then(|v| v.as_str()).unwrap_or(&query).to_string();
|
|
let chat_state = req.get("chat_state").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
let mode = req.get("mode").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
match db::save_search_history(id, query, title, chat_state, mode) {
|
|
Ok(item) => axum::Json(item).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
fn rename_search_history_handler_inner(id: String, body: axum::body::Bytes) -> Response {
|
|
let req: serde_json::Value = match serde_json::from_slice(&body) {
|
|
Ok(v) => v,
|
|
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response(),
|
|
};
|
|
let title = req.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
match db::rename_search_history(id, title) {
|
|
Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
fn pin_search_history_handler_inner(id: String, body: axum::body::Bytes) -> Response {
|
|
let req: serde_json::Value = match serde_json::from_slice(&body) {
|
|
Ok(v) => v,
|
|
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response(),
|
|
};
|
|
let pinned = req.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false);
|
|
match db::pin_search_history(id, pinned) {
|
|
Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
fn delete_search_history_handler_inner(id: String) -> Response {
|
|
match db::delete_search_history(id) {
|
|
Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
fn get_bookmarks_handler_inner() -> Response {
|
|
match db::get_bookmarks() {
|
|
Ok(items) => axum::Json(items).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
fn save_bookmark_handler_inner(body: axum::body::Bytes) -> Response {
|
|
let req: serde_json::Value = match serde_json::from_slice(&body) {
|
|
Ok(v) => v,
|
|
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(),
|
|
};
|
|
let label = req.get("label").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let history_id = req.get("history_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
match db::save_bookmark(label, history_id) {
|
|
Ok(item) => axum::Json(item).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
fn delete_bookmark_handler_inner(id: i64) -> Response {
|
|
match db::delete_bookmark(id) {
|
|
Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
fn get_video_marks_handler_inner(query_str: &str) -> Response {
|
|
let file_uuid = query_str
|
|
.split('&')
|
|
.find_map(|pair| {
|
|
let mut it = pair.splitn(2, '=');
|
|
let k = it.next().unwrap_or("");
|
|
let v = it.next().unwrap_or("");
|
|
if k == "file_uuid" { Some(v.to_string()) } else { None }
|
|
})
|
|
.unwrap_or_default();
|
|
match db::get_video_marks(file_uuid) {
|
|
Ok(items) => axum::Json(items).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
fn save_video_mark_handler_inner(body: axum::body::Bytes) -> Response {
|
|
let req: serde_json::Value = match serde_json::from_slice(&body) {
|
|
Ok(v) => v,
|
|
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(),
|
|
};
|
|
let file_uuid = req.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let tag = req.get("tag").and_then(|v| v.as_str()).unwrap_or("default").to_string();
|
|
let start_frame = req.get("start_frame").and_then(|v| v.as_i64()).unwrap_or(0);
|
|
let end_frame = req.get("end_frame").and_then(|v| v.as_i64());
|
|
let note = req.get("note").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
match db::save_video_mark(file_uuid, tag, start_frame, end_frame, note) {
|
|
Ok(item) => axum::Json(item).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
fn delete_video_mark_handler_inner(id: i64) -> Response {
|
|
match db::delete_video_mark(id) {
|
|
Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn get_identity_profile_handler_inner(uuid: String) -> Response {
|
|
let start = std::time::Instant::now();
|
|
let no_dash = uuid.replace('-', "");
|
|
eprintln!("[proxy] PROFILE handler called for {} (no_dash={})", uuid, no_dash);
|
|
{
|
|
let mut cache = get_profile_proxy_cache();
|
|
if let Some(c) = cache.as_mut() {
|
|
if let Some(val) = c.get(&uuid) {
|
|
eprintln!("[proxy] <-- PROFILE {} 200 (cached) {}ms", uuid, start.elapsed().as_millis());
|
|
return ([("content-type", "text/plain")], val.clone()).into_response();
|
|
}
|
|
}
|
|
}
|
|
let result = (|| -> Option<String> {
|
|
let output_dir = get_output_dir();
|
|
let identities_dir = output_dir.join("identities");
|
|
for candidate in [&no_dash, &uuid] {
|
|
let path = identities_dir.join(candidate).join("profile.jpg");
|
|
if path.exists() {
|
|
eprintln!("[proxy] PROFILE FOUND: {:?}", path);
|
|
let bytes = std::fs::read(&path).ok()?;
|
|
return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)));
|
|
}
|
|
}
|
|
None
|
|
})();
|
|
match result {
|
|
Some(data) => {
|
|
let mut cache = get_profile_proxy_cache();
|
|
if let Some(c) = cache.as_mut() {
|
|
c.put(uuid.clone(), data.clone());
|
|
}
|
|
eprintln!("[proxy] <-- PROFILE {} 200 {} {}ms", uuid, data.len(), start.elapsed().as_millis());
|
|
([("content-type", "text/plain")], data).into_response()
|
|
}
|
|
None => {
|
|
eprintln!("[proxy] <-- PROFILE {} 404 {}ms", uuid, start.elapsed().as_millis());
|
|
(StatusCode::NOT_FOUND, "Profile image not found").into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
|
let start = std::time::Instant::now();
|
|
let uuid = match params.get("uuid") {
|
|
Some(v) => v.clone(),
|
|
None => return (StatusCode::BAD_REQUEST, "Missing uuid").into_response(),
|
|
};
|
|
let frame: u32 = params.get("frame").and_then(|v| v.parse().ok()).unwrap_or(0);
|
|
let bbox_x = params.get("bbox_x").and_then(|v| v.parse::<f64>().ok());
|
|
let bbox_y = params.get("bbox_y").and_then(|v| v.parse::<f64>().ok());
|
|
let bbox_w = params.get("bbox_w").and_then(|v| v.parse::<f64>().ok());
|
|
let bbox_h = params.get("bbox_h").and_then(|v| v.parse::<f64>().ok());
|
|
|
|
let cache_key = format!("{}:{}:{}:{}:{}:{}", uuid, frame,
|
|
bbox_x.map_or(0.0, |v| v),
|
|
bbox_y.map_or(0.0, |v| v),
|
|
bbox_w.map_or(0.0, |v| v),
|
|
bbox_h.map_or(0.0, |v| v));
|
|
|
|
{
|
|
let mut cache = get_face_thumb_cache();
|
|
if let Some(c) = cache.as_mut() {
|
|
if let Some(val) = c.get(&cache_key) {
|
|
eprintln!("[proxy] <-- FACE-THUMB {} (cached) {}ms", uuid, start.elapsed().as_millis());
|
|
return ([("content-type", "text/plain")], val.clone()).into_response();
|
|
}
|
|
}
|
|
}
|
|
|
|
let _permit = acquire_thumb_semaphore(&uuid).acquire_owned().await.unwrap();
|
|
// Double-check cache after acquiring semaphore
|
|
{
|
|
let mut cache = get_face_thumb_cache();
|
|
if let Some(c) = cache.as_mut() {
|
|
if let Some(val) = c.get(&cache_key) {
|
|
eprintln!("[proxy] <-- FACE-THUMB {} (cached after wait) {}ms", uuid, start.elapsed().as_millis());
|
|
return ([("content-type", "text/plain")], val.clone()).into_response();
|
|
}
|
|
}
|
|
}
|
|
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, get_api_key(), frame);
|
|
|
|
let bytes = match state.client.get(&url).send().await {
|
|
Ok(resp) => match resp.bytes().await {
|
|
Ok(b) => b.to_vec(),
|
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Read failed: {}", e)).into_response(),
|
|
},
|
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
|
};
|
|
|
|
let result = if let (Some(bx), Some(by), Some(bw), Some(bh)) = (bbox_x, bbox_y, bbox_w, bbox_h) {
|
|
let img = match image::load_from_memory(&bytes) {
|
|
Ok(i) => i,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Image decode failed: {}", e)).into_response(),
|
|
};
|
|
let (w, h) = img.dimensions();
|
|
let (px, py, pw, ph) = if bx <= 1.0 && by <= 1.0 && bw <= 1.0 && bh <= 1.0 {
|
|
((bx * w as f64) as u32, (by * h as f64) as u32, (bw * w as f64) as u32, (bh * h as f64) as u32)
|
|
} else {
|
|
(bx as u32, by as u32, bw as u32, bh as u32)
|
|
};
|
|
let cx = px.min(w.saturating_sub(1));
|
|
let cy = py.min(h.saturating_sub(1));
|
|
let cw = pw.min(w.saturating_sub(cx)).max(1);
|
|
let ch = ph.min(h.saturating_sub(cy)).max(1);
|
|
let cropped = img.crop_imm(cx, cy, cw, ch);
|
|
let mut buf = std::io::Cursor::new(Vec::new());
|
|
if let Err(e) = cropped.write_to(&mut buf, image::ImageFormat::Jpeg) {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Encode failed: {}", e)).into_response();
|
|
}
|
|
format!("data:image/jpeg;base64,{}", STANDARD.encode(buf.into_inner()))
|
|
} else {
|
|
format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))
|
|
};
|
|
|
|
{
|
|
let mut cache = get_face_thumb_cache();
|
|
if let Some(c) = cache.as_mut() {
|
|
c.put(cache_key, result.clone());
|
|
}
|
|
}
|
|
|
|
eprintln!("[proxy] <-- FACE-THUMB {} {} {}ms", uuid, result.len(), start.elapsed().as_millis());
|
|
([("content-type", "text/plain")], result).into_response()
|
|
}
|
|
|
|
async fn get_identity_matches_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
|
let file_hash = match params.get("file_hash") {
|
|
Some(v) => v.clone(),
|
|
None => return (StatusCode::BAD_REQUEST, "Missing file_hash").into_response(),
|
|
};
|
|
let output_base = get_output_dir();
|
|
let match_path = format!("{}/{}.identity_match_round1.json", output_base.display(), file_hash);
|
|
let match_path = if std::path::Path::new(&match_path).exists() {
|
|
Some(match_path)
|
|
} else {
|
|
None
|
|
};
|
|
match match_path {
|
|
Some(path) => match std::fs::read_to_string(&path) {
|
|
Ok(content) => {
|
|
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response();
|
|
}
|
|
};
|
|
axum::Json(parsed).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
|
},
|
|
None => (StatusCode::NOT_FOUND, "Identity match file not found").into_response(),
|
|
}
|
|
}
|
|
|
|
async fn get_trace_profile_file_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
|
let file_uuid = match params.get("file_uuid") {
|
|
Some(v) => v.clone(),
|
|
None => return (StatusCode::BAD_REQUEST, "Missing file_uuid").into_response(),
|
|
};
|
|
let trace_id = match params.get("trace_id") {
|
|
Some(v) => v.clone(),
|
|
None => return (StatusCode::BAD_REQUEST, "Missing trace_id").into_response(),
|
|
};
|
|
let output_base = get_output_dir();
|
|
let profile_path = format!("{}/trace_{}/trace_profile.json", output_base.display(), trace_id);
|
|
let profile_path = if std::path::Path::new(&profile_path).exists() {
|
|
Some(profile_path)
|
|
} else {
|
|
None
|
|
};
|
|
match profile_path {
|
|
Some(path) => match std::fs::read_to_string(&path) {
|
|
Ok(content) => {
|
|
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response();
|
|
}
|
|
};
|
|
axum::Json(parsed).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
|
},
|
|
None => (StatusCode::NOT_FOUND, "Trace profile file not found").into_response(),
|
|
}
|
|
}
|
|
|
|
async fn get_cluster_results_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
|
let file_hash = match params.get("file_hash") {
|
|
Some(v) => v.clone(),
|
|
None => return (StatusCode::BAD_REQUEST, "Missing file_hash").into_response(),
|
|
};
|
|
|
|
let client = reqwest::Client::new();
|
|
// Try new cluster-results endpoint first, fallback to face-groups
|
|
let url = format!("{}/api/v1/cluster-results?file_hash={}", CORE_API, file_hash);
|
|
let resp = client.get(&url).header("X-API-Key", get_api_key()).send().await;
|
|
|
|
match resp {
|
|
Ok(r) => {
|
|
let status = r.status();
|
|
let b = r.text().await.unwrap_or_default();
|
|
// Check if response has data
|
|
if status.is_success() {
|
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&b) {
|
|
let fg = v.get("face_groups").and_then(|x| x.as_array()).map(|x| x.len()).unwrap_or(0);
|
|
if fg > 0 {
|
|
return ([(axum::http::header::CONTENT_TYPE, "application/json")], b).into_response();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Request failed: {}", e)).into_response(),
|
|
};
|
|
|
|
// Fallback to face-groups
|
|
let fg_url = format!("{}/api/v1/file/{}/face-groups", CORE_API, file_hash);
|
|
let fg_resp = match client.get(&fg_url).header("X-API-Key", get_api_key()).send().await {
|
|
Ok(r) => r,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Face groups request failed: {}", e)).into_response(),
|
|
};
|
|
|
|
let fg_status = fg_resp.status();
|
|
let fg_body = match fg_resp.text().await {
|
|
Ok(b) => b,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
|
};
|
|
|
|
let axum_status = axum::http::StatusCode::from_u16(fg_status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
if !axum_status.is_success() {
|
|
return (axum_status, fg_body).into_response();
|
|
}
|
|
|
|
let result: serde_json::Value = match serde_json::from_str(&fg_body) {
|
|
Ok(v) => v,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON parse failed: {}", e)).into_response(),
|
|
};
|
|
|
|
let clusters = result.get("face_groups").cloned().unwrap_or(serde_json::json!([]));
|
|
axum::Json(serde_json::json!({
|
|
"success": true,
|
|
"clusters": clusters
|
|
})).into_response()
|
|
}
|
|
|
|
async fn get_processor_json_handler(
|
|
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
|
) -> Response {
|
|
let file_hash = match params.get("file_hash") {
|
|
Some(v) => v.clone(),
|
|
None => return (StatusCode::BAD_REQUEST, "Missing file_hash").into_response(),
|
|
};
|
|
let processor = match params.get("processor") {
|
|
Some(v) => v.clone(),
|
|
None => return (StatusCode::BAD_REQUEST, "Missing processor").into_response(),
|
|
};
|
|
|
|
let client = reqwest::Client::new();
|
|
let json_url = format!("{}/api/v1/file/{}/json/{}", CORE_API, file_hash, processor);
|
|
let json_resp = match client
|
|
.post(&json_url)
|
|
.header("X-API-Key", get_api_key())
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON request failed: {}", e)).into_response(),
|
|
};
|
|
|
|
let status = json_resp.status();
|
|
let body = match json_resp.text().await {
|
|
Ok(b) => b,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
|
};
|
|
|
|
let axum_status = axum::http::StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
if axum_status.is_success() {
|
|
match serde_json::from_str::<serde_json::Value>(&body) {
|
|
Ok(v) => axum::Json(v).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON parse failed: {}", e)).into_response(),
|
|
}
|
|
} else {
|
|
(axum_status, body).into_response()
|
|
}
|
|
}
|
|
|
|
async fn get_file_thumbnail_by_path_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
|
let path = match params.get("path") {
|
|
Some(v) => v.clone(),
|
|
None => return (StatusCode::BAD_REQUEST, "Missing path").into_response(),
|
|
};
|
|
let frame: u32 = params.get("frame").and_then(|v| v.parse().ok()).unwrap_or(30);
|
|
|
|
if !std::path::Path::new(&path).exists() {
|
|
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
|
}
|
|
|
|
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
|
let is_image = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp");
|
|
|
|
if is_image {
|
|
// For images, read directly
|
|
match std::fs::read(&path) {
|
|
Ok(bytes) => {
|
|
let mime = match ext.as_str() {
|
|
"png" => "image/png",
|
|
"gif" => "image/gif",
|
|
"webp" => "image/webp",
|
|
_ => "image/jpeg",
|
|
};
|
|
([( "content-type", mime )], axum::body::Body::from(bytes)).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
|
}
|
|
} else {
|
|
// For videos, extract frame using ffmpeg
|
|
let time_sec = frame as f64 / 24.0;
|
|
let output = tokio::process::Command::new("ffmpeg")
|
|
.args([
|
|
"-ss", &time_sec.to_string(),
|
|
"-i", &path,
|
|
"-vframes", "1",
|
|
"-q:v", "2",
|
|
"-f", "mjpeg",
|
|
"-"
|
|
])
|
|
.output()
|
|
.await;
|
|
|
|
match output {
|
|
Ok(out) if out.status.success() => {
|
|
let jpeg = out.stdout;
|
|
([("content-type", "image/jpeg")], axum::body::Body::from(jpeg)).into_response()
|
|
}
|
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Thumbnail extraction failed").into_response(),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn get_frame_handler(State(state): State<ProxyState>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
|
let file_uuid = match params.get("file_uuid") {
|
|
Some(v) => v.clone(),
|
|
None => return (StatusCode::BAD_REQUEST, "Missing file_uuid").into_response(),
|
|
};
|
|
let frame: u32 = params.get("frame").and_then(|v| v.parse().ok()).unwrap_or(0);
|
|
|
|
// Get file path from Core API
|
|
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, file_uuid, get_api_key());
|
|
let file_info = match state.client.get(&url).send().await {
|
|
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
|
Ok(json) => json,
|
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Parse failed: {}", e)).into_response(),
|
|
},
|
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
|
};
|
|
|
|
let file_path = match file_info["file_path"].as_str() {
|
|
Some(p) => p.to_string(),
|
|
None => return (StatusCode::NOT_FOUND, "File path not found").into_response(),
|
|
};
|
|
|
|
if !std::path::Path::new(&file_path).exists() {
|
|
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
|
}
|
|
|
|
let ext = file_path.rsplit('.').next().unwrap_or("").to_lowercase();
|
|
let is_image = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp");
|
|
|
|
if is_image {
|
|
match std::fs::read(&file_path) {
|
|
Ok(bytes) => {
|
|
let mime = match ext.as_str() {
|
|
"png" => "image/png",
|
|
"gif" => "image/gif",
|
|
"webp" => "image/webp",
|
|
_ => "image/jpeg",
|
|
};
|
|
([("content-type", mime)], axum::body::Body::from(bytes)).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
|
}
|
|
} else {
|
|
// Use select filter for exact frame extraction instead of -ss seeking
|
|
let output = tokio::process::Command::new("ffmpeg")
|
|
.args([
|
|
"-i", &file_path,
|
|
"-vf", &format!("select=eq(n\\,{frame})"),
|
|
"-vframes", "1",
|
|
"-q:v", "2",
|
|
"-f", "mjpeg",
|
|
"-"
|
|
])
|
|
.output()
|
|
.await;
|
|
|
|
match output {
|
|
Ok(out) if out.status.success() => {
|
|
let jpeg = out.stdout;
|
|
([("content-type", "image/jpeg")], axum::body::Body::from(jpeg)).into_response()
|
|
}
|
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Frame extraction failed").into_response(),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn get_pose_handler(
|
|
axum::extract::Path(file_uuid): axum::extract::Path<String>,
|
|
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
|
) -> Response {
|
|
let frame = match params.get("frame").and_then(|v| v.parse::<u32>().ok()) {
|
|
Some(f) => f,
|
|
None => return (StatusCode::BAD_REQUEST, "Missing frame").into_response(),
|
|
};
|
|
|
|
let mut url = format!("{}/api/v1/file/{}/pose?frame={}", CORE_API, file_uuid, frame);
|
|
if let Some(bbox_x) = params.get("bbox_x") {
|
|
url.push_str(&format!("&bbox_x={}", bbox_x));
|
|
}
|
|
if let Some(bbox_y) = params.get("bbox_y") {
|
|
url.push_str(&format!("&bbox_y={}", bbox_y));
|
|
}
|
|
if let Some(bbox_w) = params.get("bbox_w") {
|
|
url.push_str(&format!("&bbox_w={}", bbox_w));
|
|
}
|
|
if let Some(bbox_h) = params.get("bbox_h") {
|
|
url.push_str(&format!("&bbox_h={}", bbox_h));
|
|
}
|
|
|
|
let client = reqwest::Client::new();
|
|
let resp = match client
|
|
.get(&url)
|
|
.header("X-API-Key", get_api_key())
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
|
};
|
|
|
|
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
let body = match resp.text().await {
|
|
Ok(b) => b,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
|
};
|
|
|
|
(status, body).into_response()
|
|
}
|
|
|
|
async fn get_appearance_handler(
|
|
axum::extract::Path(file_uuid): axum::extract::Path<String>,
|
|
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
|
) -> Response {
|
|
let frame = match params.get("frame").and_then(|v| v.parse::<u32>().ok()) {
|
|
Some(f) => f,
|
|
None => return (StatusCode::BAD_REQUEST, "Missing frame").into_response(),
|
|
};
|
|
|
|
let mut url = format!("{}/api/v1/file/{}/appearance?frame={}", CORE_API, file_uuid, frame);
|
|
if let Some(bbox_x) = params.get("bbox_x") {
|
|
url.push_str(&format!("&bbox_x={}", bbox_x));
|
|
}
|
|
if let Some(bbox_y) = params.get("bbox_y") {
|
|
url.push_str(&format!("&bbox_y={}", bbox_y));
|
|
}
|
|
if let Some(bbox_w) = params.get("bbox_w") {
|
|
url.push_str(&format!("&bbox_w={}", bbox_w));
|
|
}
|
|
if let Some(bbox_h) = params.get("bbox_h") {
|
|
url.push_str(&format!("&bbox_h={}", bbox_h));
|
|
}
|
|
|
|
let client = reqwest::Client::new();
|
|
let resp = match client
|
|
.get(&url)
|
|
.header("X-API-Key", get_api_key())
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
|
};
|
|
|
|
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
let body = match resp.text().await {
|
|
Ok(b) => b,
|
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
|
};
|
|
|
|
(status, body).into_response()
|
|
}
|
|
|
|
async fn get_admin_config_handler() -> Response {
|
|
let config_path = get_admin_config_path();
|
|
match std::fs::read_to_string(&config_path) {
|
|
Ok(content) => {
|
|
match serde_json::from_str::<serde_json::Value>(&content) {
|
|
Ok(v) => axum::Json(v).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response(),
|
|
}
|
|
}
|
|
Err(e) => (StatusCode::NOT_FOUND, format!("Config file not found: {}", e)).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn save_admin_config_handler(axum::extract::Json(payload): axum::extract::Json<serde_json::Value>) -> Response {
|
|
let config_path = get_admin_config_path();
|
|
match serde_json::to_string_pretty(&payload) {
|
|
Ok(formatted) => {
|
|
match std::fs::write(&config_path, formatted) {
|
|
Ok(()) => axum::Json(serde_json::json!({"success": true, "message": "Config saved"})).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Write failed: {}", e)).into_response(),
|
|
}
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Format failed: {}", e)).into_response(),
|
|
}
|
|
}
|
|
|
|
#[cfg(target_arch = "aarch64")]
|
|
mod macmon_sampler {
|
|
use macmon::Sampler;
|
|
|
|
pub struct MacmonData {
|
|
pub cpu_power: f32,
|
|
pub gpu_power: f32,
|
|
pub ane_power: f32,
|
|
pub sys_power: f32,
|
|
pub ram_power: f32,
|
|
pub cpu_temp: f32,
|
|
pub gpu_temp: f32,
|
|
pub ecpu_freq_mhz: u32,
|
|
pub pcpu_freq_mhz: u32,
|
|
pub gpu_freq_mhz: u32,
|
|
pub cpu_scaled_ratio: f32,
|
|
pub gpu_scaled_ratio: f32,
|
|
pub chip_name: String,
|
|
pub ecpu_cores: u8,
|
|
pub pcpu_cores: u8,
|
|
pub gpu_cores: u8,
|
|
pub ram_total: u64,
|
|
pub ram_usage: u64,
|
|
pub swap_total: u64,
|
|
pub swap_used: u64,
|
|
}
|
|
|
|
fn get_swap_info() -> (u64, u64) {
|
|
use std::process::Command;
|
|
let output = match Command::new("sysctl")
|
|
.args(["-n", "vm.swapusage"])
|
|
.output()
|
|
{
|
|
Ok(o) => o,
|
|
Err(_) => return (0, 0),
|
|
};
|
|
|
|
let s = String::from_utf8_lossy(&output.stdout);
|
|
let parts: Vec<&str> = s.split_whitespace().collect();
|
|
|
|
let total_mb = parts.get(2)
|
|
.and_then(|v| v.trim_end_matches('M').parse::<f64>().ok())
|
|
.unwrap_or(0.0) as u64;
|
|
let used_mb = parts.get(5)
|
|
.and_then(|v| v.trim_end_matches('M').parse::<f64>().ok())
|
|
.unwrap_or(0.0) as u64;
|
|
(total_mb, used_mb)
|
|
}
|
|
|
|
pub fn get_metrics() -> Option<MacmonData> {
|
|
let mut sampler = Sampler::new().ok()?;
|
|
let metrics = sampler.get_metrics(100).ok()?;
|
|
let soc = sampler.get_soc_info();
|
|
let (swap_total, swap_used) = get_swap_info();
|
|
|
|
Some(MacmonData {
|
|
cpu_power: metrics.cpu_power,
|
|
gpu_power: metrics.gpu_power,
|
|
ane_power: metrics.ane_power,
|
|
sys_power: metrics.sys_power,
|
|
ram_power: metrics.ram_power,
|
|
cpu_temp: metrics.temp.cpu_temp_avg,
|
|
gpu_temp: metrics.temp.gpu_temp_avg,
|
|
ecpu_freq_mhz: metrics.ecpu_freq_mhz,
|
|
pcpu_freq_mhz: metrics.pcpu_freq_mhz,
|
|
gpu_freq_mhz: metrics.gpu_freq_mhz,
|
|
cpu_scaled_ratio: metrics.cpu_scaled_ratio,
|
|
gpu_scaled_ratio: metrics.gpu_scaled_ratio,
|
|
chip_name: soc.chip_name.clone(),
|
|
ecpu_cores: soc.ecpu_cores,
|
|
pcpu_cores: soc.pcpu_cores,
|
|
gpu_cores: soc.gpu_cores,
|
|
ram_total: metrics.memory.ram_total,
|
|
ram_usage: metrics.memory.ram_usage,
|
|
swap_total,
|
|
swap_used,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_arch = "aarch64"))]
|
|
mod macmon_sampler {
|
|
pub struct MacmonData;
|
|
pub fn get_metrics() -> Option<MacmonData> { None }
|
|
}
|
|
|
|
#[derive(serde::Serialize, serde::Deserialize)]
|
|
pub struct SystemStatsResponse {
|
|
pub cpu_used_percent: f64,
|
|
pub memory_used_percent: f64,
|
|
pub memory_available_mb: u64,
|
|
pub memory_total_mb: u64,
|
|
pub swap_used_percent: f64,
|
|
pub swap_available_mb: u64,
|
|
pub swap_total_mb: u64,
|
|
pub gpu_available: bool,
|
|
pub gpu_utilization: Option<f64>,
|
|
pub gpu_memory_used_pct: Option<f64>,
|
|
pub ane_available: bool,
|
|
pub ane_utilization: Option<f64>,
|
|
pub source: String,
|
|
pub chip_name: Option<String>,
|
|
pub cpu_power: Option<f64>,
|
|
pub gpu_power: Option<f64>,
|
|
pub ane_power: Option<f64>,
|
|
pub sys_power: Option<f64>,
|
|
pub ram_power: Option<f64>,
|
|
pub cpu_temp: Option<f64>,
|
|
pub gpu_temp: Option<f64>,
|
|
pub ecpu_freq_mhz: Option<u32>,
|
|
pub pcpu_freq_mhz: Option<u32>,
|
|
pub gpu_freq_mhz: Option<u32>,
|
|
pub ecpu_cores: Option<u8>,
|
|
pub pcpu_cores: Option<u8>,
|
|
pub gpu_cores: Option<u8>,
|
|
}
|
|
|
|
async fn get_system_stats_handler() -> Response {
|
|
// 1. Try macmon first (Apple Silicon only)
|
|
if let Some(m) = macmon_sampler::get_metrics() {
|
|
let ram_total_mb = m.ram_total / 1024 / 1024;
|
|
let ram_usage_mb = m.ram_usage / 1024 / 1024;
|
|
let ram_available_mb = ram_total_mb.saturating_sub(ram_usage_mb);
|
|
let memory_used_percent = if ram_total_mb > 0 {
|
|
(ram_usage_mb as f64 / ram_total_mb as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let swap_total_mb = m.swap_total;
|
|
let swap_used_mb = m.swap_used;
|
|
let swap_available_mb = swap_total_mb.saturating_sub(swap_used_mb);
|
|
let swap_used_percent = if swap_total_mb > 0 {
|
|
(swap_used_mb as f64 / swap_total_mb as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let stats = SystemStatsResponse {
|
|
cpu_used_percent: m.cpu_scaled_ratio as f64 * 100.0,
|
|
memory_used_percent,
|
|
memory_available_mb: ram_available_mb,
|
|
memory_total_mb: ram_total_mb,
|
|
swap_used_percent,
|
|
swap_available_mb,
|
|
swap_total_mb,
|
|
gpu_available: true,
|
|
gpu_utilization: Some(m.gpu_scaled_ratio as f64 * 100.0),
|
|
gpu_memory_used_pct: None,
|
|
ane_available: m.ane_power > 0.0,
|
|
ane_utilization: None,
|
|
source: "macmon".to_string(),
|
|
chip_name: Some(m.chip_name),
|
|
cpu_power: Some(m.cpu_power as f64),
|
|
gpu_power: Some(m.gpu_power as f64),
|
|
ane_power: Some(m.ane_power as f64),
|
|
sys_power: Some(m.sys_power as f64),
|
|
ram_power: Some(m.ram_power as f64),
|
|
cpu_temp: Some(m.cpu_temp as f64),
|
|
gpu_temp: Some(m.gpu_temp as f64),
|
|
ecpu_freq_mhz: Some(m.ecpu_freq_mhz),
|
|
pcpu_freq_mhz: Some(m.pcpu_freq_mhz),
|
|
gpu_freq_mhz: Some(m.gpu_freq_mhz),
|
|
ecpu_cores: Some(m.ecpu_cores),
|
|
pcpu_cores: Some(m.pcpu_cores),
|
|
gpu_cores: Some(m.gpu_cores),
|
|
};
|
|
return axum::Json(stats).into_response();
|
|
}
|
|
|
|
// 2. Fallback: Use Core API
|
|
let client = reqwest::Client::new();
|
|
let url = format!("{}/health/detailed", CORE_API);
|
|
|
|
match client.get(&url).header("api_key", get_api_key()).send().await {
|
|
Ok(resp) => {
|
|
match resp.json::<serde_json::Value>().await {
|
|
Ok(data) => {
|
|
if let Some(resources) = data.get("resources") {
|
|
let stats = SystemStatsResponse {
|
|
cpu_used_percent: resources.get("cpu_used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
memory_used_percent: resources.get("memory_used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
|
memory_available_mb: resources.get("memory_available_mb").and_then(|v| v.as_u64()).unwrap_or(0),
|
|
memory_total_mb: resources.get("memory_total_mb").and_then(|v| v.as_u64()).unwrap_or(0),
|
|
swap_used_percent: 0.0,
|
|
swap_available_mb: 0,
|
|
swap_total_mb: 0,
|
|
gpu_available: resources.get("gpu_available").and_then(|v| v.as_bool()).unwrap_or(false),
|
|
gpu_utilization: resources.get("gpu_utilization").and_then(|v| v.as_f64()),
|
|
gpu_memory_used_pct: resources.get("gpu_memory_used_pct").and_then(|v| v.as_f64()),
|
|
ane_available: resources.get("ane_available").and_then(|v| v.as_bool()).unwrap_or(false),
|
|
ane_utilization: resources.get("ane_utilization").and_then(|v| v.as_f64()),
|
|
source: "fallback".to_string(),
|
|
chip_name: None,
|
|
cpu_power: None,
|
|
gpu_power: None,
|
|
ane_power: None,
|
|
sys_power: None,
|
|
ram_power: None,
|
|
cpu_temp: None,
|
|
gpu_temp: None,
|
|
ecpu_freq_mhz: None,
|
|
pcpu_freq_mhz: None,
|
|
gpu_freq_mhz: None,
|
|
ecpu_cores: None,
|
|
pcpu_cores: None,
|
|
gpu_cores: None,
|
|
};
|
|
axum::Json(stats).into_response()
|
|
} else {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "No resources in response").into_response()
|
|
}
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response(),
|
|
}
|
|
}
|
|
Err(e) => (StatusCode::BAD_GATEWAY, format!("Core API error: {}", e)).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn validate_system_stats_handler(
|
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
|
) -> Response {
|
|
// Parse layer parameter
|
|
let layers: Vec<u32> = params
|
|
.get("layers")
|
|
.map(|s| s.split(',').filter_map(|n| n.trim().parse().ok()).collect())
|
|
.unwrap_or_else(|| vec![1, 2, 4]);
|
|
|
|
// Get current macmon stats
|
|
let stats = if let Some(m) = macmon_sampler::get_metrics() {
|
|
let ram_total_mb = m.ram_total / 1024 / 1024;
|
|
let ram_usage_mb = m.ram_usage / 1024 / 1024;
|
|
let ram_available_mb = ram_total_mb.saturating_sub(ram_usage_mb);
|
|
let memory_used_percent = if ram_total_mb > 0 {
|
|
(ram_usage_mb as f64 / ram_total_mb as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let swap_total_mb = m.swap_total;
|
|
let swap_used_mb = m.swap_used;
|
|
let swap_available_mb = swap_total_mb.saturating_sub(swap_used_mb);
|
|
let swap_used_percent = if swap_total_mb > 0 {
|
|
(swap_used_mb as f64 / swap_total_mb as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
SystemStatsResponse {
|
|
cpu_used_percent: m.cpu_scaled_ratio as f64 * 100.0,
|
|
memory_used_percent,
|
|
memory_available_mb: ram_available_mb,
|
|
memory_total_mb: ram_total_mb,
|
|
swap_used_percent,
|
|
swap_available_mb,
|
|
swap_total_mb,
|
|
gpu_available: true,
|
|
gpu_utilization: Some(m.gpu_scaled_ratio as f64 * 100.0),
|
|
gpu_memory_used_pct: None,
|
|
ane_available: m.ane_power > 0.0,
|
|
ane_utilization: None,
|
|
source: "macmon".to_string(),
|
|
chip_name: Some(m.chip_name),
|
|
cpu_power: Some(m.cpu_power as f64),
|
|
gpu_power: Some(m.gpu_power as f64),
|
|
ane_power: Some(m.ane_power as f64),
|
|
sys_power: Some(m.sys_power as f64),
|
|
ram_power: Some(m.ram_power as f64),
|
|
cpu_temp: Some(m.cpu_temp as f64),
|
|
gpu_temp: Some(m.gpu_temp as f64),
|
|
ecpu_freq_mhz: Some(m.ecpu_freq_mhz),
|
|
pcpu_freq_mhz: Some(m.pcpu_freq_mhz),
|
|
gpu_freq_mhz: Some(m.gpu_freq_mhz),
|
|
ecpu_cores: Some(m.ecpu_cores),
|
|
pcpu_cores: Some(m.pcpu_cores),
|
|
gpu_cores: Some(m.gpu_cores),
|
|
}
|
|
} else {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, "macmon not available").into_response();
|
|
};
|
|
|
|
// Run validation
|
|
let result = super::validate::run_validation(&layers, &stats);
|
|
|
|
axum::Json(result).into_response()
|
|
}
|