refactor: purge Node.js, Rust is the one true backend

- Moved boosapi-rs/* to repo root
- Deleted all Node.js source (src/server/, src/web/, scripts/)
- Deleted node_modules, package.json, tsconfig, vite.config
- Updated .gitignore and AGENTS.md for Rust-only stack
- Fixed static file path for frontend dist/web/
- Dockerfile stays the same (already Rust-only)
- Kept dist/web/ for Rust static serving
This commit is contained in:
boos
2026-05-18 09:11:29 +08:00
parent 725f44699a
commit 5d29f03d50
1165 changed files with 271 additions and 323571 deletions
+371
View File
@@ -0,0 +1,371 @@
// ComfyUI proxy routes — forward to local ComfyUI instance
//
// These routes proxy requests to a local ComfyUI server for AI video/image workflow editing.
// All business routes require downstream API key or admin token authentication.
// The /api/comfyui/proxy/* route serves static assets (public).
use crate::{
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use reqwest::Client;
use serde_json::Value;
use std::collections::HashMap;
use tracing::info;
/// Default ComfyUI backend address.
const DEFAULT_COMFYUI_BASE: &str = "http://127.0.0.1:8188";
/// Shared HTTP client
fn http_client() -> Client {
Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.expect("Failed to build HTTP client")
}
// ── Auth helper (same pattern as proxy module) ────────────────────────
async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.and_then(|v| v.trim().split_whitespace().next())
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
// Admin token passes
if auth == state.config.auth.auth_token {
return Ok(());
}
// Downstream API key
if auth.starts_with("sk-") {
let result = authorize_downstream_token(auth, &state.db)
.await
.map_err(|e| AppError::Unauthorized(e))?;
match result {
DownstreamAuthResult::Managed { key, .. } => {
if !key.enabled.unwrap_or(true) {
return Err(AppError::Forbidden("API key is disabled".into()));
}
Ok(())
}
DownstreamAuthResult::Global { .. } => Ok(()),
}
} else {
Err(AppError::Unauthorized("Invalid token".into()))
}
}
/// Get ComfyUI base URL from env or default
fn comfyui_base() -> String {
std::env::var("COMFYUI_BASE").unwrap_or_else(|_| DEFAULT_COMFYUI_BASE.to_string())
}
// ── Route builder ────────────────────────────────────────────────────
pub fn routes() -> Router<AppState> {
Router::new()
// Business routes (require auth)
.route("/api/comfyui/prompt", post(proxy_post_prompt))
.route("/api/comfyui/history", get(proxy_get_history))
.route("/api/comfyui/history/{id}", get(proxy_get_history_by_id))
.route("/api/comfyui/queue", get(proxy_get_queue))
.route("/api/comfyui/view", get(proxy_get_view))
// Static proxy (public — assets/JS/CSS)
.route("/api/comfyui/proxy/{*path}", get(proxy_static))
// HTML page (public)
.route("/comfyui", get(comfyui_page))
}
// ── Business route handlers (require auth) ───────────────────────────
/// POST /api/comfyui/prompt
async fn proxy_post_prompt(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Response> {
info!("POST /api/comfyui/prompt");
check_auth(&state, &headers).await?;
proxy_to_comfyui("POST", "/prompt", None, Some(body)).await
}
/// GET /api/comfyui/history
async fn proxy_get_history(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Response> {
info!("GET /api/comfyui/history");
check_auth(&state, &headers).await?;
proxy_to_comfyui("GET", "/history", None, None).await
}
/// GET /api/comfyui/history/{id}
async fn proxy_get_history_by_id(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Response> {
info!("GET /api/comfyui/history/{}", id);
check_auth(&state, &headers).await?;
let path = format!("/history/{}", urlencoding(&id));
proxy_to_comfyui("GET", &path, None, None).await
}
/// GET /api/comfyui/queue
async fn proxy_get_queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
info!("GET /api/comfyui/queue");
check_auth(&state, &headers).await?;
proxy_to_comfyui("GET", "/queue", None, None).await
}
/// GET /api/comfyui/view?filename=...&type=...&subfolder=...
async fn proxy_get_view(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> AppResult<Response> {
info!("GET /api/comfyui/view");
check_auth(&state, &headers).await?;
let qs = build_query_string(&params);
let path = format!("/view{}", qs);
proxy_to_comfyui_binary("GET", &path).await
}
// ── Static proxy (public — assets/JS/CSS for iframe) ─────────────────
/// GET /api/comfyui/proxy/{path} — proxy everything (HTML, assets)
async fn proxy_static(Path(path): Path<String>) -> AppResult<Response> {
info!("GET /api/comfyui/proxy/{}", path);
let target_path = if path.is_empty() || path == "/" {
"/".to_string()
} else {
format!("/{}", path)
};
let client = http_client();
let url = format!("{}{}", comfyui_base(), target_path);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
if content_type.contains("html") {
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
// Rebrand: ComfyUI → BoosAPI
let rebranded = text
.replace("ComfyUI", "BoosAPI")
.replace("comfyui", "BoosAPI");
// Rewrite URLs
let rewritten = rewrite_proxy_urls(&rebranded);
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
rewritten,
)
.into_response())
} else if content_type.contains("text") || content_type.contains("json") {
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, content_type)],
text,
)
.into_response())
} else {
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}
}
/// GET /comfyui — HTML page with iframe
async fn comfyui_page() -> Html<&'static str> {
Html(
r##"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BoosAPI - ComfyUI</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
iframe { width: 100%; height: 100%; border: none; }
</style>
</head>
<body>
<iframe src="/api/comfyui/proxy/" title="BoosAPI ComfyUI"></iframe>
</body>
</html>"##,
)
}
// ── Helpers ──────────────────────────────────────────────────────────
/// Rewrite URLs in HTML so asset paths go through the proxy
fn rewrite_proxy_urls(html: &str) -> String {
// Replace src="/...", href="/...", action="/..." with /api/comfyui/proxy/...
let mut result = String::with_capacity(html.len() * 12 / 10);
let mut chars = html.char_indices().peekable();
while let Some((_, ch)) = chars.next() {
if ch == 's' || ch == 'h' || ch == 'a' {
// Look ahead for rc="/", ref="/", ction="/"
let snippet: String = chars.clone().take(6).map(|(_, c)| c).collect();
let full = format!("{}{}", ch, snippet);
if full.starts_with("src=\"/")
|| full.starts_with("href=\"/")
|| full.starts_with("action=\"/")
{
let attr_end = if full.starts_with("src=\"") {
4
} else if full.starts_with("href=\"") {
5
} else {
7
};
result.push_str(&full[..attr_end]);
result.push_str("/api/comfyui/proxy/");
// Skip the rest of the attribute
for _ in 0..snippet.len() {
chars.next();
}
continue;
}
}
result.push(ch);
}
result
}
/// Build query string from params
fn build_query_string(params: &HashMap<String, String>) -> String {
if params.is_empty() {
return String::new();
}
let mut parts: Vec<String> = params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding(k), urlencoding(v)))
.collect();
parts.sort(); // deterministic ordering
format!("?{}", parts.join("&"))
}
/// Simple URL encoding (handles the chars we need)
fn urlencoding(s: &str) -> String {
s.replace('%', "%25")
.replace(' ', "%20")
.replace('?', "%3F")
.replace('&', "%26")
.replace('#', "%23")
.replace('/', "%2F")
}
/// Proxy to ComfyUI — returns JSON/text response
async fn proxy_to_comfyui(
method: &str,
path: &str,
_query: Option<&HashMap<String, String>>,
body: Option<Value>,
) -> AppResult<Response> {
let client = http_client();
let url = format!("{}{}", comfyui_base(), path);
let req = match method {
"POST" => {
let body_str = body
.map(|b| b.to_string())
.unwrap_or_else(|| "{}".to_string());
client
.post(&url)
.header("Content-Type", "application/json")
.body(body_str)
}
_ => client.get(&url),
};
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, "application/json")],
text,
)
.into_response())
}
/// Proxy to ComfyUI — returns binary response (for images)
async fn proxy_to_comfyui_binary(_method: &str, path: &str) -> AppResult<Response> {
let client = http_client();
let url = format!("{}{}", comfyui_base(), path);
let req = client.get(&url);
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
let status = resp.status();
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}