feat: bind/unbind by id, video streaming proxy, unmute default, library refresh, hamburger click-outside

- Core API: bind/unbind accept id (integer PK) when face_id is null
- Tauri proxy: stream video responses instead of buffering for seek bar
- VideoPlayer: remove muted attribute, unmute by default
- LibraryView: call invalidateFiles() before ensureFiles() on register/unregister
- PeopleView: close sort panel on click-outside, not just hamburger toggle
- bind_face: prefer face_id, fallback to id when face_id is null
This commit is contained in:
2026-06-18 22:24:03 +08:00
parent 97af4da331
commit 7855983dc1
19 changed files with 5322 additions and 794 deletions

View File

@@ -7,16 +7,33 @@ license = "MIT"
repository = ""
edition = "2021"
rust-version = "1.77.2"
default-run = "momentry-studio"
[[bin]]
name = "momentry-studio"
path = "src/main.rs"
[[bin]]
name = "momentry-proxy"
path = "src/bin/proxy.rs"
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "json"] }
reqwest = { version = "0.11", features = ["json"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "json"] }
reqwest = { version = "0.11", features = ["json", "stream", "multipart"] }
tokio = { version = "1", features = ["full"] }
base64 = "0.22"
lru = "0.12"
futures = "0.3"
image = { version = "0.24", default-features = false, features = ["jpeg"] }
axum = "0.7"
tower-http = { version = "0.5", features = ["cors", "fs"] }
rusqlite = { version = "0.32", features = ["bundled"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
[build-dependencies]
tauri-build = { version = "2", features = [] }

View File

@@ -0,0 +1,11 @@
// Standalone proxy binary for testing/development
// Re-exports the proxy module from main crate
fn main() {
// We can't import from the main crate as a library easily,
// so instead we'll just call through the Tauri binary mechanism.
// For now, the full Tauri binary must be used to run the proxy.
eprintln!("Use `cargo tauri dev` to start the full application with proxy.");
eprintln!("For standalone proxy testing, the proxy runs as part of the Tauri binary.");
std::process::exit(1);
}

238
src-tauri/src/db.rs Normal file
View File

@@ -0,0 +1,238 @@
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Mutex;
const APP_TABLES: &str = "
CREATE TABLE IF NOT EXISTS search_history (
id TEXT PRIMARY KEY,
query TEXT NOT NULL,
title TEXT NOT NULL,
chat_state TEXT,
mode TEXT DEFAULT 'keyword',
pinned INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
history_id TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS app_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT,
role TEXT DEFAULT 'user',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_search_history_sort ON search_history(pinned DESC, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_bookmarks_created ON bookmarks(created_at DESC);
";
static DB_CONN: Mutex<Option<Connection>> = Mutex::new(None);
fn db_path() -> PathBuf {
let base = std::env::var("MOMENTRY_DATA_DIR").unwrap_or_else(|_| "../data/users".to_string());
std::fs::create_dir_all(&base).ok();
PathBuf::from(base).join("demo.sqlite")
}
pub fn init_db() -> Result<(), String> {
let path = db_path();
let conn = Connection::open(&path).map_err(|e| format!("Failed to open db: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|e| format!("WAL mode error: {}", e))?;
conn.execute_batch(APP_TABLES).map_err(|e| format!("Failed to create tables: {}", e))?;
let default_id = "demo";
let default_user = "demo";
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM app_users WHERE id = ?1",
[default_id],
|row| row.get(0),
)
.unwrap_or(0);
if count == 0 {
conn.execute(
"INSERT OR IGNORE INTO app_users (id, username, display_name, role) VALUES (?1, ?2, ?3, 'admin')",
rusqlite::params![default_id, default_user, "Demo User"],
)
.map_err(|e| format!("Failed to insert default user: {}", e))?;
}
let mut lock = DB_CONN.lock().map_err(|e| format!("DB lock error: {}", e))?;
*lock = Some(conn);
Ok(())
}
fn get_conn() -> Result<Connection, String> {
let path = db_path();
let conn = Connection::open(&path).map_err(|e| format!("Failed to open db: {}", e))?;
conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|e| format!("WAL mode error: {}", e))?;
Ok(conn)
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HistoryItem {
pub id: String,
pub query: String,
pub title: String,
pub chat_state: Option<String>,
pub mode: Option<String>,
pub pinned: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BookmarkItem {
pub id: i64,
pub label: String,
pub history_id: Option<String>,
pub created_at: Option<String>,
}
#[tauri::command(rename_all = "camelCase")]
pub fn get_search_history(limit: Option<u32>) -> Result<Vec<HistoryItem>, String> {
let limit = limit.unwrap_or(30).min(30);
let conn = get_conn()?;
let mut stmt = conn
.prepare("SELECT id, query, title, chat_state, mode, pinned, created_at, updated_at FROM search_history ORDER BY pinned DESC, updated_at DESC LIMIT ?1")
.map_err(|e| format!("Prepare error: {}", e))?;
let items = stmt
.query_map([limit], |row| {
Ok(HistoryItem {
id: row.get(0)?,
query: row.get(1)?,
title: row.get(2)?,
chat_state: row.get(3)?,
mode: row.get(4)?,
pinned: row.get::<_, i64>(5)? != 0,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
})
.map_err(|e| format!("Query error: {}", e))?
.filter_map(|r| r.ok())
.collect();
Ok(items)
}
#[tauri::command(rename_all = "camelCase")]
pub fn save_search_history(
id: String,
query: String,
title: String,
chat_state: Option<String>,
mode: Option<String>,
) -> Result<HistoryItem, String> {
let conn = get_conn()?;
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let effective_mode = mode.unwrap_or_else(|| "keyword".to_string());
conn.execute(
"INSERT OR REPLACE INTO search_history (id, query, title, chat_state, mode, pinned, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, (SELECT COALESCE((SELECT pinned FROM search_history WHERE id = ?1), 0)), ?6)",
rusqlite::params![id, query, title, chat_state, effective_mode, now],
)
.map_err(|e| format!("Insert error: {}", e))?;
conn.execute(
"DELETE FROM search_history WHERE id NOT IN (SELECT id FROM search_history ORDER BY pinned DESC, updated_at DESC LIMIT 30)",
[],
)
.ok();
Ok(HistoryItem {
id,
query: query.clone(),
title: title.clone(),
chat_state,
mode: Some(effective_mode),
pinned: false,
created_at: None,
updated_at: Some(now),
})
}
#[tauri::command(rename_all = "camelCase")]
pub fn rename_search_history(id: String, title: String) -> Result<(), String> {
let conn = get_conn()?;
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
conn.execute(
"UPDATE search_history SET title = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![title, now, id],
)
.map_err(|e| format!("Rename error: {}", e))?;
Ok(())
}
#[tauri::command(rename_all = "camelCase")]
pub fn pin_search_history(id: String, pinned: bool) -> Result<(), String> {
let conn = get_conn()?;
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
conn.execute(
"UPDATE search_history SET pinned = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![pinned as i64, now, id],
)
.map_err(|e| format!("Pin error: {}", e))?;
Ok(())
}
#[tauri::command(rename_all = "camelCase")]
pub fn delete_search_history(id: String) -> Result<(), String> {
let conn = get_conn()?;
conn.execute("DELETE FROM search_history WHERE id = ?1", [id])
.map_err(|e| format!("Delete error: {}", e))?;
Ok(())
}
#[tauri::command(rename_all = "camelCase")]
pub fn get_bookmarks() -> Result<Vec<BookmarkItem>, String> {
let conn = get_conn()?;
let mut stmt = conn
.prepare("SELECT id, label, history_id, created_at FROM bookmarks ORDER BY created_at DESC")
.map_err(|e| format!("Prepare error: {}", e))?;
let items = stmt
.query_map([], |row| {
Ok(BookmarkItem {
id: row.get(0)?,
label: row.get(1)?,
history_id: row.get(2)?,
created_at: row.get(3)?,
})
})
.map_err(|e| format!("Query error: {}", e))?
.filter_map(|r| r.ok())
.collect();
Ok(items)
}
#[tauri::command(rename_all = "camelCase")]
pub fn save_bookmark(label: String, history_id: Option<String>) -> Result<BookmarkItem, String> {
let conn = get_conn()?;
conn.execute(
"INSERT INTO bookmarks (label, history_id) VALUES (?1, ?2)",
rusqlite::params![label, history_id],
)
.map_err(|e| format!("Insert error: {}", e))?;
let id = conn.last_insert_rowid();
Ok(BookmarkItem {
id,
label,
history_id,
created_at: None,
})
}
#[tauri::command(rename_all = "camelCase")]
pub fn delete_bookmark(id: i64) -> Result<(), String> {
let conn = get_conn()?;
conn.execute("DELETE FROM bookmarks WHERE id = ?1", [id])
.map_err(|e| format!("Delete error: {}", e))?;
Ok(())
}

File diff suppressed because it is too large Load Diff

558
src-tauri/src/proxy.rs Normal file
View File

@@ -0,0 +1,558 @@
use axum::{
body::Body,
extract::State,
http::{HeaderValue, StatusCode},
response::{IntoResponse, Response},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use crate::db;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use image::GenericImageView;
use std::sync::Mutex;
use std::num::NonZeroUsize;
use lru::LruCache;
const CORE_API: &str = "http://localhost:3002";
const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69";
const DIST_DIR: &str = "../dist";
const PROFILE_DIRS: [&str; 4] = [
"/Users/accusys/momentry/output/identities",
"/Users/accusys/momentry/output_dev/identities",
"/Volumes/external/momentry/output/identities",
"/Volumes/external/momentry/output_dev/identities",
];
static FACE_THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
static PROFILE_PROXY_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
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/face-thumbnail", axum::routing::get(get_face_thumbnail_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"
}
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_api(state, req).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") {
return handle_local_api(req).await;
}
if path.starts_with("/api/") {
return proxy_api(state, req).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 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);
}
(StatusCode::NOT_FOUND, "Not found").into_response()
}
async fn proxy_api(state: ProxyState, req: axum::extract::Request) -> 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 = format!("{}{}?api_key={}", CORE_API, path, API_KEY);
for pair in query_str.split('&') {
if pair.is_empty() || pair.starts_with("api_key=") {
continue;
}
url.push_str(&format!("&{}", pair));
}
let range_hdr = headers.get("range").and_then(|v| v.to_str().ok()).unwrap_or("-");
eprintln!("[proxy] --> {} {} (core api) range={}", method, path, 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(),
}
}
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> {
for dir in &PROFILE_DIRS {
for candidate in [&no_dash, &uuid] {
let path = format!("{}/{}/profile.jpg", dir, candidate);
if std::path::Path::new(&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 url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, 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()
}

View File

@@ -22,7 +22,13 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": [
"**"
]
}
}
},
"bundle": {