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:
@@ -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 = [] }
|
||||
|
||||
11
src-tauri/src/bin/proxy.rs
Normal file
11
src-tauri/src/bin/proxy.rs
Normal 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
238
src-tauri/src/db.rs
Normal 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
558
src-tauri/src/proxy.rs
Normal 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()
|
||||
}
|
||||
@@ -22,7 +22,13 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
Reference in New Issue
Block a user