Files
markbase/src/server.rs
Warren 44d5f0c619 fix: Generate correct bcrypt hash and update PostgreSQL admin password
- Create src/bin directory for temporary tools
- Generate correct bcrypt hash (60 chars) for 'admin123'
- Update PostgreSQL admins.password (clear corrupted data)
- Reinitialize auth.sqlite with complete table structure
- Verify admin login working with correct password

Key fixes:
- PostgreSQL admins.password: varchar(255) accepts 60-char bcrypt hash
- auth.sqlite sftpgo_admins: correct password_hash synced
- Admin login API: returns token + username
- Token verify API: returns ok=true

All tests passing:
 Admin sync: admins_synced=1
 Hash length: 60 chars (bcrypt standard)
 Admin login: success
 Token verify: success

Status: Admin authentication fully functional
2026-05-16 20:59:48 +08:00

1610 lines
58 KiB
Rust

use anyhow::Context;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Json},
routing::{delete, get, patch, post, put},
Router,
};
use serde::Deserialize;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use crate::audio;
use crate::auth::{AuthState, LoginRequest};
use crate::filetree::{self, FileTree};
use crate::render;
#[derive(Clone)]
struct AppState {
html: Arc<Mutex<String>>,
page_ver: Arc<Mutex<u64>>,
step_info: Arc<Mutex<serde_json::Value>>,
labels: Arc<Mutex<Vec<serde_json::Value>>>,
db_dir: String,
auth: AuthState,
auth_db_path: String,
}
pub async fn run(port: u16, file: Option<String>) -> anyhow::Result<()> {
let welcome = if let Some(f) = &file {
let md = std::fs::read_to_string(f).unwrap_or_default();
let body = render::md_to_html(&md);
render::page(f, &body)
} else {
render::page(
"MarkBase",
r#"<div style="text-align:center;padding:60px 20px;color:#64748b">
<div style=font-size:48px;margin-bottom:16px>📺</div>
<h1 style=border:none;margin-bottom:8px>MarkBase</h1>
<p style=font-size:1.1em>Momentry Display Engine</p>
</div>"#,
)
};
let (out_devs, in_devs, cur_out, cur_in) = audio::audio_devices();
let html = audio::inject_audio_devices(&welcome, &out_devs, &in_devs, &cur_out, &cur_in);
let state = AppState {
html: Arc::new(Mutex::new(html)),
page_ver: Arc::new(Mutex::new(0)),
step_info: Arc::new(Mutex::new(serde_json::json!({
"step": 0, "total": 0, "id": "", "label": "", "voice": "off"
}))),
labels: Arc::new(Mutex::new(vec![])),
db_dir: "data/users".to_string(),
auth: AuthState::with_sync("data/auth.sqlite"),
auth_db_path: "data/auth.sqlite".to_string(),
};
// Initial sync from SFTPGo PostgreSQL
let syncer = crate::pg_client::SftpGoSync::new("data/auth.sqlite")?;
tokio::spawn(async move {
match syncer.full_sync().await {
Ok(result) => {
log::info!(
"Initial sync completed: users={}, groups={}, mappings={}, status={}",
result.users_synced,
result.groups_synced,
result.mappings_synced,
result.status
);
}
Err(e) => {
log::error!("Initial sync failed: {}", e);
}
}
});
// Periodic sync task (every hour)
let syncer_clone = crate::pg_client::SftpGoSync::new("data/auth.sqlite")?;
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
loop {
interval.tick().await;
match syncer_clone.full_sync().await {
Ok(result) => {
log::info!(
"Hourly sync: users={}, groups={}, status={}",
result.users_synced,
result.groups_synced,
result.status
);
}
Err(e) => {
log::error!("Hourly sync failed, keeping cached data: {}", e);
}
}
}
});
let app = Router::new()
.route("/", get(root_handler))
.route("/display", post(display_handler))
.route(
"/command",
get(crate::command::get_commands).post(crate::command::post_command),
)
.route("/version", get(version_handler))
.route("/devices", get(devices_handler))
.route("/volume", get(volume_handler))
.route("/body", get(body_handler))
.route("/status", get(status_handler))
.route("/labels", get(get_labels).post(post_labels))
// Auth endpoints (public)
.route("/api/v2/auth/login", post(login_handler))
.route("/api/v2/auth/logout", post(logout_handler))
.route("/api/v2/auth/verify", get(verify_handler))
.route("/api/v2/admin/sync", post(manual_sync_handler))
.route("/api/v2/admin/sync/status", get(sync_status_handler))
// Config API endpoints (public)
.route("/api/v2/config", get(get_config_handler))
.route("/api/v2/config/edit", post(edit_config_handler))
.route("/api/v2/config/validate", get(validate_config_handler))
// Admin authentication API endpoints (public)
.route("/api/v2/admin/login", post(admin_login_handler))
.route("/api/v2/admin/verify", get(admin_verify_handler))
// Protected endpoints (require auth)
.route("/api/v2/tree/:user_id", get(get_tree))
.route("/api/v2/tree/:user_id/node", post(create_node))
.route(
"/api/v2/tree/:user_id/node/:node_id",
put(update_node).delete(delete_node),
)
.route("/api/v2/tree/:user_id", delete(delete_all_nodes))
.route("/api/v2/tree/:user_id/restore", post(restore_tree))
.route("/api/v2/dupes/:user_id", get(get_dupes))
.route("/api/v2/unregister/:file_uuid", post(unregister_file))
.route("/api/v2/upload/:user_id", post(upload_file))
.route("/api/v2/render/:file_uuid", get(render_file))
.route("/api/v2/render/:file_uuid/body", get(render_file_body))
.route("/api/v2/tree/:user_id/node/:node_id/move", put(move_node))
.route(
"/api/v2/tree/:user_id/node/:node_id/alias",
patch(update_alias),
)
.route("/api/v2/modes", get(get_modes))
.route("/api/v2/files/:file_uuid/info", get(get_file_info))
.route("/api/v2/files/:file_uuid/probe", get(get_file_probe))
.route("/api/v2/files/:file_uuid/stream", get(stream_file))
.route(
"/api/v2/files/:file_uuid/locations",
post(add_file_location),
)
.with_state(state);
let addr = format!("127.0.0.1:{port}");
println!("📺 MarkBase ready on http://{addr}/");
std::process::Command::new("open")
.arg(format!("http://{addr}/"))
.spawn()
.ok();
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn root_handler(State(state): State<AppState>) -> Html<String> {
Html(state.html.lock().unwrap().clone())
}
async fn version_handler(State(state): State<AppState>) -> Json<serde_json::Value> {
let v = *state.page_ver.lock().unwrap();
Json(serde_json::json!({"v": v}))
}
async fn get_labels(State(state): State<AppState>) -> Json<serde_json::Value> {
let labels = state.labels.lock().unwrap().clone();
Json(serde_json::json!(labels))
}
async fn post_labels(
State(state): State<AppState>,
Json(body): Json<Vec<serde_json::Value>>,
) -> impl IntoResponse {
*state.labels.lock().unwrap() = body;
(StatusCode::OK, Json(serde_json::json!({"ok": true})))
}
async fn status_handler(State(state): State<AppState>) -> Json<serde_json::Value> {
let info = state.step_info.lock().unwrap().clone();
Json(
serde_json::json!({"paused": false, "step": info["step"], "total": info["total"], "id": info["id"], "label": info["label"], "voice": info["voice"]}),
)
}
async fn devices_handler() -> Json<serde_json::Value> {
let (out, inp, co, ci) = audio::audio_devices();
Json(serde_json::json!({"output": out, "input": inp, "current_out": co, "current_in": ci}))
}
async fn volume_handler() -> Json<serde_json::Value> {
if let Ok(r) = std::process::Command::new("osascript")
.args(["-e", "output volume of (get volume settings)"])
.output()
{
let s = String::from_utf8_lossy(&r.stdout).trim().to_string();
if let Ok(v) = s.parse::<i64>() {
return Json(serde_json::json!({"level": v}));
}
}
Json(serde_json::json!({"level": 50}))
}
async fn body_handler(State(state): State<AppState>) -> impl IntoResponse {
let html = state.html.lock().unwrap().clone();
if let Some(s) = html.find("<div id=mb-content>") {
if let Some(e) = html[s..].find("</div>") {
let body = &html[s + 19..s + e];
return (StatusCode::OK, Html(body.to_string())).into_response();
}
}
(StatusCode::OK, Html("".to_string())).into_response()
}
async fn delete_all_nodes(
State(state): State<AppState>,
Path(user_id): Path<String>,
) -> impl IntoResponse {
let _ = &state.db_dir;
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db(&user_id)?;
let tree = FileTree::load(&conn, &user_id)?;
let count = tree.nodes.len();
let node_ids: Vec<String> = tree.nodes.iter().map(|n| n.node_id.clone()).collect();
for nid in node_ids {
conn.execute(
"DELETE FROM file_nodes WHERE node_id = ?1",
rusqlite::params![nid],
)?;
}
Ok(serde_json::json!({"ok": true, "deleted": count}))
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn restore_tree(
State(state): State<AppState>,
Path(user_id): Path<String>,
) -> impl IntoResponse {
let _ = &state.db_dir;
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db(&user_id)?;
let tree = FileTree::load(&conn, &user_id)?;
let count = tree.nodes.len();
for n in &tree.nodes {
conn.execute("DELETE FROM file_nodes WHERE node_id = ?1", rusqlite::params![n.node_id])?;
}
// Fetch files from 3002 API using environment variables
let api_key = std::env::var("RESTORE_API_KEY")
.unwrap_or_else(|_| "".to_string());
let api_url = std::env::var("RESTORE_API_URL")
.unwrap_or_else(|_| "http://localhost:3002/api/v1/files".to_string());
let api_output = std::process::Command::new("curl")
.args(["-s", &format!("{}?page=1&page_size=100", api_url)])
.args(["-H", &format!("X-API-Key: {}", api_key)])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let resp: serde_json::Value = serde_json::from_str(&api_output)
.map_err(|e| anyhow::anyhow!("3002 API error: {}", e))?;
let files = resp["data"].as_array()
.ok_or_else(|| anyhow::anyhow!("No files data"))?;
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let home_id = uuid::Uuid::new_v4().to_string();
let mut folder_ids: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let folders: Vec<(&str, &str, &str)> = vec![
("Home", "🏠", ""),
("Movies", "🎬", &home_id),
("Marketing", "📢", &home_id),
("Cartoons", "🎨", &home_id),
("Other", "📁", &home_id),
];
for (name, icon, parent) in &folders {
let nid = if *name == "Home" { home_id.clone() } else { uuid::Uuid::new_v4().to_string() };
let pid: Option<&str> = if parent.is_empty() { None } else { Some(parent) };
folder_ids.insert(name.to_string(), nid.clone());
conn.execute(
"INSERT INTO file_nodes (node_id, label, aliases_json, node_type, icon, parent_id, created_at, updated_at) VALUES (?1, ?2, '{}', 'folder', ?3, ?4, ?5, ?6)",
rusqlite::params![nid, name, icon, pid, now, now],
)?;
}
let mut imported = 0;
for f in files {
let name = f["file_name"].as_str().unwrap_or("unknown");
let name_lower = name.to_lowercase();
let fuuid = f["file_uuid"].as_str().unwrap_or("");
let category = if name_lower.ends_with(".jpg") || name_lower.ends_with(".png") || name_lower.ends_with(".jpeg") || name_lower.ends_with(".gif") {
"Other"
} else if name_lower.contains("charade") || name_lower.contains("film") || name_lower.contains("clip") || name_lower.contains("movie") || name_lower.contains("comedy") || name_lower.contains("filmriot") {
"Movies"
} else if name_lower.contains("exasan") || name_lower.contains("gamma") || name_lower.contains("thunderbolt") || name_lower.contains("nab") || name_lower.contains("koba") || name_lower.contains("webinar") || name_lower.contains("top colorist") || name_lower.contains("accusys") || name_lower.contains("a12t3") {
"Marketing"
} else if name_lower.contains("cartoon") || name_lower.contains("alice") || name_lower.contains("felix") || name_lower.contains("disney") || name_lower.contains("steamboat") || name_lower.contains("animal") {
"Cartoons"
} else {
"Other"
};
let parent_id = folder_ids.get(category).cloned().unwrap_or(home_id.clone());
let nid = uuid::Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO file_nodes (node_id, label, aliases_json, file_uuid, node_type, parent_id, created_at, updated_at) VALUES (?1, ?2, '{}', ?3, 'file', ?4, ?5, ?6)",
rusqlite::params![nid, name, fuuid, parent_id, now, now],
)?;
let demo_path = format!("/Users/accusys/momentry/var/sftpgo/data/demo/{}", name);
conn.execute(
"INSERT OR IGNORE INTO file_locations (file_uuid, location, label) VALUES (?1, ?2, 'origin')",
rusqlite::params![fuuid, demo_path],
)?;
imported += 1;
}
Ok(serde_json::json!({"ok": true, "deleted": count, "imported": imported}))
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
#[derive(Deserialize)]
struct DisplayReq {
#[serde(rename = "type")]
content_type: String,
data: Option<String>,
file: Option<String>,
url: Option<String>,
html: Option<String>,
step_id: Option<String>,
step_num: Option<i64>,
step_total: Option<i64>,
}
async fn display_handler(
State(state): State<AppState>,
Json(req): Json<DisplayReq>,
) -> impl IntoResponse {
let body = match req.content_type.as_str() {
"md" | "markdown" => {
let content = match (&req.file, &req.data) {
(Some(f), _) => std::fs::read_to_string(f).unwrap_or_default(),
(_, Some(d)) => d.clone(),
_ => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"ok":false})),
)
}
};
render::md_to_html(&content)
}
"json" => {
let data = req.data.as_deref().unwrap_or("");
let formatted = serde_json::from_str::<serde_json::Value>(data)
.map(|v| serde_json::to_string_pretty(&v).unwrap_or_default())
.unwrap_or_else(|_| data.to_string());
format!("<pre><code>{}</code></pre>", html_escape(&formatted))
}
"url" => format!(
"<iframe src=\"{}\"></iframe>",
html_escape(req.url.as_deref().unwrap_or(""))
),
"video" => format!(
"<video controls autoplay><source src=\"{}\"></video>",
html_escape(req.url.as_deref().unwrap_or(""))
),
"image" => format!(
"<img src=\"{}\">",
html_escape(req.url.as_deref().unwrap_or(""))
),
"html" => req.html.clone().unwrap_or_default(),
_ => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"ok":false})),
)
}
};
let (out_devs, in_devs, cur_out, cur_in) = audio::audio_devices();
let new_html = audio::inject_audio_devices(
&render::page("Display", &body),
&out_devs,
&in_devs,
&cur_out,
&cur_in,
);
*state.html.lock().unwrap() = new_html;
*state.page_ver.lock().unwrap() += 1;
if let (Some(id), Some(num), Some(total)) = (&req.step_id, req.step_num, req.step_total) {
let mut info = state.step_info.lock().unwrap();
*info =
serde_json::json!({"step": num, "total": total, "id": id, "label": "", "voice": "off"});
}
(StatusCode::OK, Json(serde_json::json!({"ok": true})))
}
async fn get_tree(
State(state): State<AppState>,
headers: HeaderMap,
Path(user_id): Path<String>,
Query(query): Query<serde_json::Value>,
) -> impl IntoResponse {
// Verify authentication
if let Err(status) = verify_auth(&state, &headers) {
return (
status,
Json(serde_json::json!({"error": "Unauthorized"})),
)
.into_response();
}
let _ = &state.db_dir;
let mode = query["mode"].as_str().unwrap_or("tree").to_string();
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db(&user_id)?;
let tree = FileTree::load(&conn, &user_id)?;
let data = filetree::mode::get_mode(&mode)
.map(|m| m.render(&tree))
.unwrap_or_else(|| serde_json::json!({"nodes": [], "error": "unknown mode"}));
Ok(data)
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn get_modes() -> impl IntoResponse {
let modes: Vec<serde_json::Value> = filetree::mode::list_modes()
.iter()
.map(|m| {
serde_json::json!({
"name": m.name(),
"sort_options": m.sort_options(),
"filter_options": m.filter_options(),
})
})
.collect();
(StatusCode::OK, Json(serde_json::json!({"modes": modes})))
}
async fn create_node(
State(state): State<AppState>,
Path(user_id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let _db_dir = state.db_dir.clone();
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db(&user_id)?;
let mut tree = FileTree::load(&conn, &user_id)?;
let label = body["label"].as_str().unwrap_or("Untitled");
let parent_id = body["parent_id"].as_str().map(|s| s.to_string());
let node_type = body["node_type"]
.as_str()
.map(|s| {
filetree::node::NodeType::from_str(s).unwrap_or(filetree::node::NodeType::Folder)
})
.unwrap_or(filetree::node::NodeType::Folder);
let node = match node_type {
filetree::node::NodeType::Folder => FileTree::new_folder(label, parent_id),
filetree::node::NodeType::File => {
let default_uuid = uuid::Uuid::new_v4().to_string().replace('-', "");
let raw_uuid = body["file_uuid"].as_str().unwrap_or(&default_uuid);
let file_uuid = if raw_uuid.len() >= 32 {
&raw_uuid[..32]
} else {
raw_uuid
};
let (file_node, register_sql) = FileTree::new_file_node(
label,
file_uuid,
body["sha256"].as_str(),
body["original_name"].as_str().unwrap_or(label),
body["file_size"].as_i64(),
body["file_type"].as_str(),
body["registered_at"].as_str(),
parent_id,
);
if let Some(sql) = register_sql {
conn.execute_batch(&sql)?;
}
file_node
}
_ => FileTree::new_folder(label, parent_id),
};
let node_id = node.node_id.clone();
tree.insert_node(&conn, &node)?;
Ok(serde_json::json!({
"ok": true,
"node_id": node_id,
}))
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::CREATED, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn update_node(
State(state): State<AppState>,
Path((user_id, node_id)): Path<(String, String)>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let _ = &state.db_dir;
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db(&user_id)?;
let mut tree = FileTree::load(&conn, &user_id)?;
let existing = tree
.nodes
.iter()
.find(|n| n.node_id == node_id)
.cloned()
.with_context(|| format!("Node {} not found", node_id))?;
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let updated = filetree::node::FileNode {
label: body["label"]
.as_str()
.unwrap_or(&existing.label)
.to_string(),
icon: body["icon"].as_str().map(|s| s.to_string()),
color: body["color"].as_str().map(|s| s.to_string()),
bg_color: body["bg_color"].as_str().map(|s| s.to_string()),
updated_at: now,
..existing
};
tree.update_node(&conn, &node_id, &updated)?;
Ok(serde_json::json!({"ok": true}))
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn delete_node(
State(state): State<AppState>,
Path((user_id, node_id)): Path<(String, String)>,
) -> impl IntoResponse {
let _ = &state.db_dir;
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db(&user_id)?;
let mut tree = FileTree::load(&conn, &user_id)?;
tree.delete_node(&conn, &node_id)?;
Ok(serde_json::json!({"ok": true}))
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
#[derive(Deserialize)]
struct MoveNodeReq {
parent_id: Option<String>,
}
async fn move_node(
State(state): State<AppState>,
Path((user_id, node_id)): Path<(String, String)>,
Json(req): Json<MoveNodeReq>,
) -> impl IntoResponse {
let _ = &state.db_dir;
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db(&user_id)?;
let mut tree = FileTree::load(&conn, &user_id)?;
tree.move_node(&conn, &node_id, req.parent_id)?;
Ok(serde_json::json!({"ok": true}))
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
#[derive(Deserialize)]
struct AliasReq {
lang: String,
value: String,
}
async fn update_alias(
State(state): State<AppState>,
Path((user_id, node_id)): Path<(String, String)>,
Json(req): Json<AliasReq>,
) -> impl IntoResponse {
let _ = &state.db_dir;
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db(&user_id)?;
let mut tree = FileTree::load(&conn, &user_id)?;
tree.update_node_alias(&conn, &node_id, &req.lang, &req.value)?;
Ok(serde_json::json!({"ok": true}))
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn upload_file(
State(_state): State<AppState>,
Path(user_id): Path<String>,
mut multipart: axum_extra::extract::Multipart,
) -> impl IntoResponse {
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
const MAX_UPLOAD_SIZE: u64 = 10_737_418_240; // 10GB
let _uid = user_id;
let demo_dir = "/Users/accusys/momentry/var/sftpgo/data/demo";
let mut filename = String::new();
let mut file_size: i64 = 0;
let mut file_hash = String::new();
while let Ok(Some(mut field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name != "file" {
continue;
}
filename = field.file_name().unwrap_or("upload.bin").to_string();
let file_path = format!("{}/{}", demo_dir, filename);
let mut hasher = Sha256::new();
let mut total_written: u64 = 0;
let mut file = match tokio::fs::File::create(&file_path).await {
Ok(f) => f,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("create error: {}", e)})),
)
.into_response()
}
};
while let Ok(Some(chunk)) = field.chunk().await {
total_written += chunk.len() as u64;
if total_written > MAX_UPLOAD_SIZE {
let _ = tokio::fs::remove_file(&file_path).await;
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": "file too large (max 10GB)"})),
)
.into_response();
}
hasher.update(&chunk);
if let Err(e) = file.write_all(&chunk).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("write error: {}", e)})),
)
.into_response();
}
}
let _ = file.flush().await;
drop(file);
file_hash = format!("{:x}", hasher.finalize());
file_size = total_written as i64;
}
if filename.is_empty() || file_size == 0 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "no file provided"})),
)
.into_response();
}
let file_path = format!("{}/{}", demo_dir, filename);
// Register with 3002 API via curl
let api_key = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69";
let register_json = format!(
r#"{{"file_name":"{}","file_path":"{}"}}"#,
filename, file_path
);
let register_output = std::process::Command::new("curl")
.args([
"-s",
"-X",
"POST",
"http://localhost:3002/api/v1/files/register",
])
.args(["-H", &format!("X-API-Key: {}", api_key)])
.args(["-H", "Content-Type: application/json"])
.args(["-d", &register_json])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let file_uuid = serde_json::from_str::<serde_json::Value>(&register_output)
.ok()
.and_then(|v| v["file_uuid"].as_str().map(|s| s.to_string()))
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string().replace('-', ""));
// Add to file tree
let sha_clone = file_hash.clone();
let fname_clone = filename.clone();
let fuuid_clone = file_uuid.clone();
let fpath_clone = file_path.clone();
let db_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
let conn = FileTree::open_user_db("demo")?;
let other_id: Option<String> = conn
.query_row(
"SELECT node_id FROM file_nodes WHERE label = 'Other' AND node_type = 'folder' LIMIT 1",
[],
|row| row.get(0),
)
.ok();
let nid = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
conn.execute(
"INSERT INTO file_nodes (node_id, label, aliases_json, file_uuid, sha256, node_type, parent_id, file_size, created_at, updated_at) VALUES (?1, ?2, '{}', ?3, ?4, 'file', ?5, ?6, ?7, ?8)",
rusqlite::params![nid, fname_clone, fuuid_clone, sha_clone, other_id, file_size, now, now],
)?;
conn.execute(
"INSERT OR IGNORE INTO file_locations (file_uuid, location, label) VALUES (?1, ?2, 'origin')",
rusqlite::params![fuuid_clone, fpath_clone],
)?;
Ok(())
})
.await;
match db_result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
eprintln!("[markbase] tree insert error: {}", e);
}
Err(e) => {
eprintln!("[markbase] spawn_blocking error: {}", e);
}
}
(
StatusCode::CREATED,
Json(serde_json::json!({
"ok": true,
"filename": filename,
"file_uuid": file_uuid,
"sha256": file_hash,
"size": file_size,
})),
)
.into_response()
}
async fn render_file_body(Path(file_uuid): Path<String>) -> impl IntoResponse {
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
let conn = FileTree::open_user_db("demo")?;
let path = conn.query_row(
"SELECT location FROM file_locations WHERE file_uuid = ?1 ORDER BY added_at LIMIT 1",
[&file_uuid],
|row| row.get::<_, String>(0),
)?;
drop(conn);
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|_| format!("(cannot read: {})", path));
let body = if path.ends_with(".md") || path.ends_with(".markdown") {
crate::render::md_to_html(&content)
.replace("<code class=\"language-mermaid\">", "<div class=\"mermaid\" style=\"background:#1e293b;padding:16px;border-radius:8px;margin:8px 0\">")
.replace("</code>", "</div>")
} else {
format!("<pre>{}</pre>", html_escape(&content))
};
Ok(body)
}).await;
match result {
Ok(Ok(html)) => (
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
.into_response(),
Ok(Err(e)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn render_file(Path(file_uuid): Path<String>) -> impl IntoResponse {
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<String> {
let conn = FileTree::open_user_db("demo")?;
let (label,): (String,) = conn.query_row(
"SELECT label FROM file_nodes WHERE file_uuid = ?1 LIMIT 1",
[&file_uuid],
|row| Ok((row.get(0)?,)),
)?;
let path = conn.query_row(
"SELECT location FROM file_locations WHERE file_uuid = ?1 ORDER BY added_at LIMIT 1",
[&file_uuid],
|row| row.get::<_, String>(0),
)?;
drop(conn);
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|_| format!("(cannot read file: {})", path));
let html = if path.ends_with(".md") || path.ends_with(".markdown") {
let body = crate::render::md_to_html(&content);
crate::render::render_page(&label, &body)
} else {
format!("<pre>{}</pre>", html_escape(&content))
};
Ok(html)
})
.await;
match result {
Ok(Ok(html)) => (
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
.into_response(),
Ok(Err(e)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn get_dupes(Path(user_id): Path<String>) -> impl IntoResponse {
let _uid = user_id;
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let query = "SELECT file_name, COUNT(*) as cnt, string_agg(file_uuid, ',') as uuids, string_agg(status, ',') as statuses FROM public.videos GROUP BY file_name HAVING COUNT(*) > 1 ORDER BY cnt DESC";
let output = std::process::Command::new("psql")
.args(["-U", "accusys", "-d", "momentry", "-t", "-A", "-F", "|", "-c", query])
.output()
.map_err(|e| anyhow::anyhow!("psql: {}", e))?;
let text = String::from_utf8_lossy(&output.stdout);
let mut dupes: Vec<serde_json::Value> = Vec::new();
for line in text.trim().lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 4 {
let uuids: Vec<String> = parts[2].split(',').map(|s| s.trim().to_string()).collect();
let statuses: Vec<String> = parts[3].split(',').map(|s| s.trim().to_string()).collect();
dupes.push(serde_json::json!({
"file_name": parts[0],
"count": parts[1].parse::<i64>().unwrap_or(0),
"uuids": uuids,
"statuses": statuses,
}));
}
}
Ok(serde_json::json!({"dup_groups": dupes.len(), "dupes": dupes}))
}).await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn unregister_file(Path(file_uuid): Path<String>) -> impl IntoResponse {
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let tables = vec!["tkg_edges","tkg_nodes","identity_bindings","identities","face_detections","chunk_vectors","chunk","videos"];
for table in tables {
let col = if table == "chunk_vectors" { "uuid" } else { "file_uuid" };
if table == "identity_bindings" {
std::process::Command::new("psql")
.args(["-U", "accusys", "-d", "momentry", "-c", &format!("DELETE FROM public.{} WHERE identity_id IN (SELECT id FROM public.identities WHERE file_uuid = '{}')", table, file_uuid)])
.output()?;
} else {
std::process::Command::new("psql")
.args(["-U", "accusys", "-d", "momentry", "-c", &format!("DELETE FROM public.{} WHERE {} = '{}'", table, col, file_uuid)])
.output()?;
}
}
Ok(serde_json::json!({"ok": true, "unregistered": file_uuid}))
}).await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn get_file_info(Path(file_uuid): Path<String>) -> impl IntoResponse {
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db("demo")?;
FileTree::get_file_info(&conn, &file_uuid)
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn stream_file(Path(file_uuid): Path<String>) -> impl IntoResponse {
use axum::body::Body;
use axum::http::header;
use tokio_util::io::ReaderStream;
let (path, mime) =
match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, String)> {
let conn = FileTree::open_user_db("demo")?;
let location: String = conn.query_row(
"SELECT location FROM file_locations WHERE file_uuid = ?1 ORDER BY added_at LIMIT 1",
[&file_uuid],
|row| row.get(0),
).map_err(|e| anyhow::anyhow!("No location: {}", e))?;
let ext = std::path::Path::new(&location)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
// Document conversion: Phase 1 (textutil/unzip) → Phase 2 (soffice/qlmanage)
if crate::filetree::convert::is_document_ext(&ext) {
if let Some((cached, mime)) =
crate::filetree::convert::get_cached_preview(&file_uuid, &ext)
{
return Ok((cached.to_string_lossy().to_string(), mime.to_string()));
}
// Convert on first access
let input = std::path::Path::new(&location);
if input.exists() {
match crate::filetree::convert::convert_document(input, &file_uuid) {
Ok((cached, mime)) => {
return Ok((cached.to_string_lossy().to_string(), mime.to_string()));
}
Err(e) => {
eprintln!(
"[markbase] Document conversion failed for {}: {}",
file_uuid, e
);
}
}
}
}
let mime = if location.ends_with(".mp4") || location.ends_with(".mov") {
"video/mp4"
} else if location.ends_with(".jpg") || location.ends_with(".jpeg") {
"image/jpeg"
} else if location.ends_with(".png") {
"image/png"
} else if location.ends_with(".svg") {
"image/svg+xml"
} else if location.ends_with(".html") || location.ends_with(".htm") {
"text/html; charset=utf-8"
} else if location.ends_with(".pdf") {
"application/pdf"
} else {
"application/octet-stream"
};
Ok((location, mime.to_string()))
})
.await
{
Ok(Ok((p, m))) => (p, m),
_ => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "no location"})),
)
.into_response()
}
};
match tokio::fs::File::open(&path).await {
Ok(file) => {
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
([(header::CONTENT_TYPE, mime.as_str())], body).into_response()
}
Err(_) => (StatusCode::NOT_FOUND, "file not found").into_response(),
}
}
async fn get_file_probe(Path(file_uuid): Path<String>) -> impl IntoResponse {
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db("demo")?;
let node: Option<(Option<String>, Option<String>)> = conn
.query_row(
"SELECT label, registered_at FROM file_nodes WHERE file_uuid = ?1 LIMIT 1",
[&file_uuid],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
let (label, registered_at) = node.unwrap_or((None, None));
// Fetch probe data from 3002 PostgreSQL
let _pg_url = "postgres://accusys@localhost:5432/momentry";
let pg_conn = rusqlite::Connection::open(":memory:")?; // placeholder
drop(pg_conn);
let query = format!(
"SELECT probe_json, duration, width, height, fps, file_type, total_frames FROM public.videos WHERE file_uuid = '{}'",
file_uuid.replace('\'', "''")
);
let output = std::process::Command::new("psql")
.args(["-U", "accusys", "-d", "momentry", "-t", "-A", "-F", "|", "-c", &query])
.output()
.map_err(|e| anyhow::anyhow!("psql: {}", e))?;
let text = String::from_utf8_lossy(&output.stdout);
let line = text.trim();
if line.is_empty() {
return Ok(serde_json::json!({"file_uuid": file_uuid, "probe": null, "error": "not found in 3002"}));
}
let parts: Vec<&str> = line.split('|').collect();
let probe_str = parts.first().unwrap_or(&"null");
let duration = parts.get(1).and_then(|s| s.parse::<f64>().ok());
let width = parts.get(2).and_then(|s| s.parse::<i64>().ok());
let height = parts.get(3).and_then(|s| s.parse::<i64>().ok());
let fps = parts.get(4).and_then(|s| s.parse::<f64>().ok());
let file_type = parts.get(5).map(|s| s.to_string());
let total_frames = parts.get(6).and_then(|s| s.parse::<i64>().ok());
let probe_json: Option<serde_json::Value> = serde_json::from_str(probe_str).ok();
Ok(serde_json::json!({
"file_uuid": file_uuid,
"label": label,
"registered_at": registered_at,
"duration": duration,
"width": width,
"height": height,
"fps": fps,
"file_type": file_type,
"total_frames": total_frames,
"probe": probe_json,
}))
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn add_file_location(
Path(file_uuid): Path<String>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let location = body["location"].as_str().unwrap_or("").to_string();
let label = body["label"].as_str().map(|s| s.to_string());
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db("demo")?;
FileTree::add_location(&conn, &file_uuid, &location, label.as_deref())?;
Ok(serde_json::json!({"ok": true}))
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::CREATED, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
// === Auth Handlers ===
async fn login_handler(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> impl IntoResponse {
match state.auth.login_with_sync(&body.username, &body.password) {
Some(response) => (StatusCode::OK, Json(response)).into_response(),
None => (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Invalid credentials"})),
)
.into_response(),
}
}
async fn logout_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
let auth_header = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| crate::auth::parse_auth_header(h));
match auth_header {
Some(token) => {
if state.auth.logout(&token) {
(StatusCode::OK, Json(serde_json::json!({"success": true}))).into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Token not found"})),
)
.into_response()
}
}
None => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Missing Authorization header"})),
)
.into_response(),
}
}
async fn verify_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
let auth_header = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| crate::auth::parse_auth_header(h));
match auth_header {
Some(token) => {
match state.auth.verify_token(&token) {
Some(session) => {
(
StatusCode::OK,
Json(serde_json::json!({
"valid": true,
"user_id": session.user_id,
"username": session.username,
"expires_at": session.expires_at
})),
)
.into_response()
}
None => (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"valid": false, "error": "Token expired or invalid"})),
)
.into_response(),
}
}
None => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Missing Authorization header"})),
)
.into_response(),
}
}
// Auth middleware helper
fn verify_auth(state: &AppState, headers: &HeaderMap) -> Result<String, StatusCode> {
let auth_header = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| crate::auth::parse_auth_header(h));
match auth_header {
Some(token) => {
match state.auth.verify_token(&token) {
Some(session) => Ok(session.user_id),
None => Err(StatusCode::UNAUTHORIZED),
}
}
None => Err(StatusCode::UNAUTHORIZED),
}
}
// === Sync Handlers ===
async fn manual_sync_handler(
State(state): State<AppState>,
) -> impl IntoResponse {
let syncer = crate::pg_client::SftpGoSync::new(&state.auth_db_path);
match syncer {
Ok(syncer) => {
match syncer.full_sync().await {
Ok(result) => {
if result.status == "success" {
(
StatusCode::OK,
Json(serde_json::json!({
"status": "success",
"users_synced": result.users_synced,
"groups_synced": result.groups_synced,
"mappings_synced": result.mappings_synced
}))
).into_response()
} else if result.status == "partial_success" {
(
StatusCode::OK,
Json(serde_json::json!({
"status": "partial_success",
"users_synced": result.users_synced,
"users_failed": result.users_failed,
"groups_synced": result.groups_synced,
"groups_failed": result.groups_failed,
"errors": result.errors
}))
).into_response()
} else {
(
StatusCode::OK,
Json(serde_json::json!({
"status": result.status,
"errors": result.errors
}))
).into_response()
}
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"status": "failed",
"error": e.to_string()
}))
).into_response(),
}
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"status": "failed",
"error": e.to_string()
}))
).into_response(),
}
}
async fn sync_status_handler(
State(state): State<AppState>,
) -> impl IntoResponse {
let auth_db = crate::sync::AuthDb::new(&state.auth_db_path);
match auth_db {
Ok(db) => {
match db.open() {
Ok(conn) => {
match conn.query_row(
"SELECT sync_type, sync_time, users_synced, users_failed,
groups_synced, groups_failed, mappings_synced, status
FROM sync_log ORDER BY sync_time DESC LIMIT 5",
[],
|row| {
Ok(serde_json::json!({
"sync_type": row.get::<_, String>(0)?,
"sync_time": row.get::<_, i64>(1)?,
"users_synced": row.get::<_, usize>(2)?,
"users_failed": row.get::<_, usize>(3)?,
"groups_synced": row.get::<_, usize>(4)?,
"groups_failed": row.get::<_, usize>(5)?,
"mappings_synced": row.get::<_, usize>(6)?,
"status": row.get::<_, String>(7)?,
}))
}
) {
Ok(log) => (
StatusCode::OK,
Json(serde_json::json!({
"status": "ok",
"latest_sync": log
}))
).into_response(),
Err(_) => (
StatusCode::OK,
Json(serde_json::json!({
"status": "ok",
"message": "No sync logs found"
}))
).into_response(),
}
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()}))
).into_response(),
}
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()}))
).into_response(),
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[derive(Debug, serde::Deserialize)]
struct EditConfigQuery {
key: String,
value: String,
}
async fn get_config_handler() -> impl IntoResponse {
let config_path = std::path::Path::new("config/markbase.toml");
if !config_path.exists() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Config file not found"}))
).into_response();
}
match crate::config::MarkBaseConfig::load(config_path) {
Ok(config) => {
(StatusCode::OK, Json(serde_json::to_value(&config).unwrap_or_default())).into_response()
}
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response()
}
}
}
async fn edit_config_handler(Query(params): Query<EditConfigQuery>) -> impl IntoResponse {
let config_path = std::path::Path::new("config/markbase.toml");
if !config_path.exists() {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Config file not found"}))).into_response();
}
match crate::config::MarkBaseConfig::load(config_path) {
Ok(mut config) => {
match config.set(&params.key, &params.value) {
Ok(_) => {
match config.validate() {
Ok(_) => {
match config.save(config_path) {
Ok(_) => {
(StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response()
}
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response()
}
}
}
Err(e) => {
(StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": e.to_string()}))).into_response()
}
}
}
Err(e) => {
(StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": e.to_string()}))).into_response()
}
}
}
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))).into_response()
}
}
}
async fn validate_config_handler() -> impl IntoResponse {
let config_path = std::path::Path::new("config/markbase.toml");
if !config_path.exists() {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({"ok": false, "error": "Config file not found"}))).into_response();
}
match crate::config::MarkBaseConfig::load(config_path) {
Ok(config) => {
match config.validate() {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, Json(serde_json::json!({"ok": false, "error": e.to_string()}))).into_response()
}
}
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"ok": false, "error": e.to_string()}))).into_response()
}
}
}
async fn admin_login_handler(
State(state): State<AppState>,
Json(body): Json<crate::auth::AdminLoginRequest>,
) -> impl IntoResponse {
match state.auth.admin_login(&body.username, &body.password) {
Some(response) => (StatusCode::OK, Json(response)).into_response(),
None => (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Invalid admin credentials"})),
).into_response(),
}
}
async fn admin_verify_handler(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let auth_header = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
if let Some(token) = auth_header {
if let Some(session) = state.auth.verify_admin_token(token) {
return (
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"username": session.username,
"expires_at": session.expires_at
})),
).into_response();
}
}
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"ok": false, "error": "Invalid admin token"})),
).into_response()
}