feat: frame-based positioning and mark system foundation
Core Changes: - Fix SearchView to use start_frame/end_frame directly (no time*fps conversion) - Add hard_delete support to delete_trace API - VideoPlayer: Main timeline + Mark system foundation - Proxy: Add local routes for auth, media, identity-matches, cluster-results - Add .gitignore to exclude build artifacts and dependencies Design Documents: - Multi-track Mark system design (.opencode/plans/) - Video editing positioning standards research Files Modified: - src/views/SearchView.vue: Frame positioning, ensureMinDuration (240 frames) - src/views/PeopleView.vue: batchDeleteGroups with hard_delete - src/api/index.ts: delete_trace with hard_delete body - src/components/VideoPlayer.vue: Timeline + Mark UI - src-tauri/src/proxy.rs: New local routes - AGENTS.md: Update documentation
This commit is contained in:
@@ -2,6 +2,8 @@ use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use argon2::{self, Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version};
|
||||
use argon2::password_hash::{SaltString, rand_core::OsRng};
|
||||
|
||||
const APP_TABLES: &str = "
|
||||
CREATE TABLE IF NOT EXISTS search_history (
|
||||
@@ -25,6 +27,7 @@ CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
display_name TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
@@ -49,19 +52,27 @@ pub fn init_db() -> Result<(), String> {
|
||||
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))?;
|
||||
|
||||
// Add password_hash column if not exists (migration)
|
||||
conn.execute("ALTER TABLE app_users ADD COLUMN password_hash TEXT", []).ok();
|
||||
|
||||
let default_id = "demo";
|
||||
let default_user = "demo";
|
||||
let count: i64 = conn
|
||||
let default_password = "demo123";
|
||||
|
||||
// Check if demo user exists and has password
|
||||
let password_hash: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM app_users WHERE id = ?1",
|
||||
"SELECT password_hash FROM app_users WHERE id = ?1",
|
||||
[default_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if count == 0 {
|
||||
.unwrap_or(None);
|
||||
|
||||
if password_hash.is_none() || password_hash.as_ref().map_or(true, |h| h.is_empty()) {
|
||||
let password_hash = hash_password(default_password);
|
||||
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"],
|
||||
"INSERT OR REPLACE INTO app_users (id, username, password_hash, display_name, role) VALUES (?1, ?2, ?3, ?4, 'admin')",
|
||||
rusqlite::params![default_id, default_user, password_hash, "Demo User"],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default user: {}", e))?;
|
||||
}
|
||||
@@ -98,6 +109,39 @@ pub struct BookmarkItem {
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginResult {
|
||||
pub success: bool,
|
||||
pub user: Option<User>,
|
||||
pub token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> String {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());
|
||||
argon2.hash_password(password.as_bytes(), &salt).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn verify_password(hash: &str, password: &str) -> bool {
|
||||
let parsed_hash = PasswordHash::new(hash);
|
||||
match parsed_hash {
|
||||
Ok(h) => {
|
||||
let argon2 = Argon2::default();
|
||||
argon2.verify_password(password.as_bytes(), &h).is_ok()
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
@@ -235,4 +279,58 @@ pub fn delete_bookmark(id: i64) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM bookmarks WHERE id = ?1", [id])
|
||||
.map_err(|e| format!("Delete error: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_user(username: &str, password: &str) -> Result<Option<User>, String> {
|
||||
let conn = get_conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, username, password_hash, display_name, role FROM app_users WHERE username = ?1")
|
||||
.map_err(|e| format!("Prepare error: {}", e))?;
|
||||
|
||||
let result = stmt
|
||||
.query_row([username], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let username: String = row.get(1)?;
|
||||
let password_hash: Option<String> = row.get(2)?;
|
||||
let display_name: Option<String> = row.get(3)?;
|
||||
let role: String = row.get(4)?;
|
||||
Ok((id, username, password_hash, display_name, role))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok((id, uname, password_hash, display_name, role)) => {
|
||||
if let Some(hash) = password_hash {
|
||||
if verify_password(&hash, password) {
|
||||
Ok(Some(User { id, username: uname, display_name, role }))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_by_username(username: &str) -> Result<Option<User>, String> {
|
||||
let conn = get_conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, username, display_name, role FROM app_users WHERE username = ?1")
|
||||
.map_err(|e| format!("Prepare error: {}", e))?;
|
||||
|
||||
let result = stmt
|
||||
.query_row([username], |row| {
|
||||
Ok(User {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
display_name: row.get(2)?,
|
||||
role: row.get(3)?,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(user) => Ok(Some(user)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user