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
+299
View File
@@ -0,0 +1,299 @@
// Account token routes — CRUD for account-level API tokens
use crate::{
db::models::account::AccountToken,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use chrono::Utc;
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
use uuid::Uuid;
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/account-tokens",
get(list_account_tokens).post(create_account_token),
)
.route(
"/api/account-tokens/sync-all",
post(sync_all_account_tokens),
)
.route(
"/api/account-tokens/{id}",
get(get_account_token)
.put(update_account_token)
.delete(delete_account_token),
)
}
#[derive(Debug, Deserialize)]
pub struct ListAccountTokensQuery {
#[serde(rename = "accountId", alias = "account_id")]
pub account_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountTokenRequest {
pub account_id: i64,
pub name: Option<String>,
pub token: Option<String>,
pub source: Option<String>,
pub token_group: Option<String>,
pub value_status: Option<String>,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAccountTokenRequest {
pub account_id: Option<i64>,
pub name: Option<String>,
pub token: Option<String>,
pub source: Option<String>,
pub token_group: Option<String>,
pub value_status: Option<String>,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
}
/// GET /api/account-tokens?accountId=
pub async fn list_account_tokens(
State(state): State<AppState>,
Query(query): Query<ListAccountTokensQuery>,
) -> AppResult<Json<Value>> {
info!(
"Listing account tokens, account_id filter: {:?}",
query.account_id
);
let tokens: Vec<AccountToken> = if let Some(account_id) = query.account_id {
sqlx::query_as::<_, AccountToken>(
"SELECT * FROM account_tokens WHERE account_id = ? ORDER BY token_group, id",
)
.bind(account_id)
.fetch_all(&*state.db)
.await?
} else {
sqlx::query_as::<_, AccountToken>(
"SELECT * FROM account_tokens ORDER BY account_id, token_group, id",
)
.fetch_all(&*state.db)
.await?
};
Ok(Json(json!({ "data": tokens })))
}
/// POST /api/account-tokens
pub async fn create_account_token(
State(state): State<AppState>,
Json(payload): Json<CreateAccountTokenRequest>,
) -> AppResult<Json<Value>> {
info!(
"Creating account token for account_id: {}",
payload.account_id
);
ensure_account_exists(&state, payload.account_id).await?;
let token = normalize_create_token(payload.token);
let name = payload
.name
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "API Token".to_string());
let value_status = payload
.value_status
.map(|status| status.trim().to_string())
.filter(|status| !status.is_empty())
.unwrap_or_else(|| "valid".to_string());
let now = Utc::now().to_rfc3339();
let result = sqlx::query(
"INSERT INTO account_tokens \
(account_id, name, token, source, token_group, value_status, enabled, is_default, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(payload.account_id)
.bind(&name)
.bind(&token)
.bind(&payload.source)
.bind(&payload.token_group)
.bind(&value_status)
.bind(payload.enabled.unwrap_or(true))
.bind(payload.is_default.unwrap_or(false))
.bind(&now)
.bind(&now)
.execute(&*state.db)
.await?;
let token_id = result.last_insert_rowid();
let account_token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(token_id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": account_token })))
}
/// GET /api/account-tokens/{id}
pub async fn get_account_token(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Getting account token: {}", id);
let account_token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
Ok(Json(json!({ "data": account_token })))
}
/// PUT /api/account-tokens/{id}
pub async fn update_account_token(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<UpdateAccountTokenRequest>,
) -> AppResult<Json<Value>> {
info!("Updating account token: {}", id);
let existing: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
let account_id = payload.account_id.unwrap_or(existing.account_id);
if payload.account_id.is_some() {
ensure_account_exists(&state, account_id).await?;
}
let name = payload
.name
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.unwrap_or(existing.name);
let token = match payload.token {
Some(token) => {
let token = token.trim().to_string();
if token.is_empty() {
return Err(AppError::BadRequest("token cannot be empty".into()));
}
token
}
None => existing.token,
};
let value_status = payload
.value_status
.map(|status| status.trim().to_string())
.filter(|status| !status.is_empty())
.unwrap_or(existing.value_status);
let now = Utc::now().to_rfc3339();
sqlx::query(
"UPDATE account_tokens SET \
account_id = ?, name = ?, token = ?, source = ?, token_group = ?, value_status = ?, \
enabled = ?, is_default = ?, updated_at = ? \
WHERE id = ?",
)
.bind(account_id)
.bind(&name)
.bind(&token)
.bind(payload.source.or(existing.source))
.bind(payload.token_group.or(existing.token_group))
.bind(&value_status)
.bind(payload.enabled.or(existing.enabled))
.bind(payload.is_default.or(existing.is_default))
.bind(&now)
.bind(id)
.execute(&*state.db)
.await?;
let account_token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": account_token })))
}
/// DELETE /api/account-tokens/{id}
pub async fn delete_account_token(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Deleting account token: {}", id);
let affected = sqlx::query("DELETE FROM account_tokens WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Account token with id {} not found",
id
)));
}
Ok(Json(json!({ "success": true })))
}
/// POST /api/account-tokens/sync-all
pub async fn sync_all_account_tokens() -> (StatusCode, Json<Value>) {
info!("Sync all account tokens requested");
(
StatusCode::NOT_IMPLEMENTED,
Json(json!({
"error": "not implemented"
})),
)
}
async fn ensure_account_exists(state: &AppState, account_id: i64) -> AppResult<()> {
let account_exists: Option<(i64,)> = sqlx::query_as("SELECT id FROM accounts WHERE id = ?")
.bind(account_id)
.fetch_optional(&*state.db)
.await?;
if account_exists.is_none() {
return Err(AppError::BadRequest(format!(
"Account with id {} not found",
account_id
)));
}
Ok(())
}
fn normalize_create_token(token: Option<String>) -> String {
token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty())
.unwrap_or_else(|| Uuid::new_v4().to_string())
}
+195
View File
@@ -0,0 +1,195 @@
use crate::{
db::models::account::Account,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct ListAccountsQuery {
pub site_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct CreateAccountRequest {
pub site_id: i64,
pub username: Option<String>,
pub access_token: String,
pub api_token: Option<String>,
pub status: Option<String>,
pub checkin_enabled: Option<bool>,
pub extra_config: Option<String>,
pub oauth_provider: Option<String>,
pub oauth_account_key: Option<String>,
pub oauth_project_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateAccountRequest {
pub username: Option<String>,
pub access_token: Option<String>,
pub api_token: Option<String>,
pub status: Option<String>,
pub checkin_enabled: Option<bool>,
pub extra_config: Option<String>,
pub oauth_provider: Option<String>,
pub oauth_account_key: Option<String>,
pub oauth_project_id: Option<String>,
}
/// GET /api/accounts?site_id=
pub async fn list_accounts(
State(state): State<AppState>,
Query(query): Query<ListAccountsQuery>,
) -> AppResult<Json<Value>> {
info!("Listing accounts, site_id filter: {:?}", query.site_id);
let accounts: Vec<Account> = if let Some(site_id) = query.site_id {
sqlx::query_as::<_, Account>(
"SELECT * FROM accounts WHERE site_id = ? ORDER BY sort_order, id",
)
.bind(site_id)
.fetch_all(&*state.db)
.await?
} else {
sqlx::query_as::<_, Account>("SELECT * FROM accounts ORDER BY site_id, sort_order, id")
.fetch_all(&*state.db)
.await?
};
Ok(Json(json!({ "data": accounts })))
}
/// POST /api/accounts
pub async fn create_account(
State(state): State<AppState>,
Json(payload): Json<CreateAccountRequest>,
) -> AppResult<(StatusCode, Json<Value>)> {
info!("Creating account for site_id: {}", payload.site_id);
if payload.access_token.trim().is_empty() {
return Err(AppError::BadRequest("access_token is required".into()));
}
let site_exists: Option<(i64,)> = sqlx::query_as("SELECT id FROM sites WHERE id = ?")
.bind(payload.site_id)
.fetch_optional(&*state.db)
.await?;
if site_exists.is_none() {
return Err(AppError::BadRequest(format!(
"Site with id {} not found",
payload.site_id
)));
}
let status = payload.status.unwrap_or_else(|| "active".to_string());
let result = sqlx::query(
"INSERT INTO accounts (site_id, username, access_token, api_token, status, checkin_enabled, extra_config, oauth_provider, oauth_account_key, oauth_project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(payload.site_id)
.bind(&payload.username)
.bind(&payload.access_token)
.bind(&payload.api_token)
.bind(&status)
.bind(payload.checkin_enabled)
.bind(&payload.extra_config)
.bind(&payload.oauth_provider)
.bind(&payload.oauth_account_key)
.bind(&payload.oauth_project_id)
.execute(&*state.db)
.await?;
let account_id = result.last_insert_rowid();
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
.bind(account_id)
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "data": account }))))
}
/// GET /api/accounts/{id}
pub async fn get_account(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Getting account: {}", id);
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
Ok(Json(json!({ "data": account })))
}
/// PUT /api/accounts/{id}
pub async fn update_account(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<UpdateAccountRequest>,
) -> AppResult<Json<Value>> {
info!("Updating account: {}", id);
let existing: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
sqlx::query(
"UPDATE accounts SET username = ?, access_token = ?, api_token = ?, status = ?, checkin_enabled = ?, extra_config = ?, oauth_provider = ?, oauth_account_key = ?, oauth_project_id = ?, updated_at = datetime('now') WHERE id = ?"
)
.bind(payload.username.or(existing.username))
.bind(payload.access_token.as_ref().unwrap_or(&existing.access_token))
.bind(payload.api_token.or(existing.api_token))
.bind(payload.status.unwrap_or(existing.status))
.bind(payload.checkin_enabled.or(existing.checkin_enabled))
.bind(payload.extra_config.or(existing.extra_config))
.bind(payload.oauth_provider.or(existing.oauth_provider))
.bind(payload.oauth_account_key.or(existing.oauth_account_key))
.bind(payload.oauth_project_id.or(existing.oauth_project_id))
.bind(id)
.execute(&*state.db)
.await?;
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": account })))
}
/// DELETE /api/accounts/{id}
pub async fn delete_account(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Deleting account: {}", id);
let affected = sqlx::query("DELETE FROM accounts WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Account with id {} not found",
id
)));
}
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
}
+196
View File
@@ -0,0 +1,196 @@
use crate::{
db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreateDownstreamKeyRequest {
pub name: String,
pub description: Option<String>,
pub group_name: Option<String>,
pub tags: Option<String>,
pub enabled: Option<bool>,
pub expires_at: Option<String>,
pub max_cost: Option<f64>,
pub max_requests: Option<i64>,
pub supported_models: Option<Vec<String>>,
pub allowed_route_ids: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateDownstreamKeyRequest {
pub name: Option<String>,
pub description: Option<String>,
pub group_name: Option<String>,
pub tags: Option<String>,
pub enabled: Option<bool>,
pub expires_at: Option<String>,
pub max_cost: Option<f64>,
pub max_requests: Option<i64>,
pub supported_models: Option<Vec<String>>,
pub allowed_route_ids: Option<String>,
}
/// Helper to validate models from a JSON string against a Vec<String>
fn validate_supported_models(models: &[String]) -> AppResult<()> {
for model in models {
if model.trim().is_empty() {
return Err(AppError::BadRequest(
"supported_models contains empty model name".into(),
));
}
}
Ok(())
}
/// GET /api/downstream-api-keys
pub async fn list_downstream_keys(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Listing downstream API keys");
let keys: Vec<DownstreamApiKey> =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys ORDER BY name")
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "data": keys })))
}
/// POST /api/downstream-api-keys
pub async fn create_downstream_key(
State(state): State<AppState>,
Json(payload): Json<CreateDownstreamKeyRequest>,
) -> AppResult<(StatusCode, Json<Value>)> {
info!("Creating downstream API key: {}", payload.name);
if payload.name.trim().is_empty() {
return Err(AppError::BadRequest("name is required".into()));
}
// Auto-generate key
let key = format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""));
// Serialize supported_models to JSON string if provided
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
validate_supported_models(models)?;
Some(
serde_json::to_string(models)
.map_err(|e| AppError::BadRequest(format!("Invalid supported_models: {}", e)))?,
)
} else {
None
};
let enabled = payload.enabled.unwrap_or(true);
let result = sqlx::query(
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(&payload.name)
.bind(&key)
.bind(&payload.description)
.bind(&payload.group_name)
.bind(&payload.tags)
.bind(enabled)
.bind(&payload.expires_at)
.bind(payload.max_cost)
.bind(payload.max_requests)
.bind(&supported_models)
.bind(&payload.allowed_route_ids)
.execute(&*state.db)
.await?;
let key_id = result.last_insert_rowid();
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(key_id)
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "data": api_key }))))
}
/// PUT /api/downstream-api-keys/{id}
pub async fn update_downstream_key(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<UpdateDownstreamKeyRequest>,
) -> AppResult<Json<Value>> {
info!("Updating downstream API key: {}", id);
let existing: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("Downstream key with id {} not found", id))
})?;
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
validate_supported_models(models)?;
Some(
serde_json::to_string(models)
.map_err(|e| AppError::BadRequest(format!("Invalid supported_models: {}", e)))?,
)
} else {
existing.supported_models.clone()
};
sqlx::query(
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, updated_at = datetime('now') WHERE id = ?"
)
.bind(payload.name.unwrap_or(existing.name))
.bind(payload.description.or(existing.description))
.bind(payload.group_name.or(existing.group_name))
.bind(payload.tags.or(existing.tags))
.bind(payload.enabled.unwrap_or(existing.enabled.unwrap_or(true)))
.bind(payload.expires_at.or(existing.expires_at))
.bind(payload.max_cost.or(existing.max_cost))
.bind(payload.max_requests.or(existing.max_requests))
.bind(&supported_models)
.bind(payload.allowed_route_ids.or(existing.allowed_route_ids))
.bind(id)
.execute(&*state.db)
.await?;
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": api_key })))
}
/// DELETE /api/downstream-api-keys/{id}
pub async fn delete_downstream_key(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Deleting downstream API key: {}", id);
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Downstream key with id {} not found",
id
)));
}
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
}
+174
View File
@@ -0,0 +1,174 @@
use crate::{
db::models::setting::Setting,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::State,
http::HeaderMap,
middleware,
response::Response,
routing::{get, put},
Json, Router,
};
use serde_json::{json, Value};
use tracing::info;
mod account_tokens;
mod accounts;
mod downstream_keys;
mod sites;
mod tokens;
mod users;
/// Middleware to require admin authentication via Bearer token.
async fn require_admin_auth(
State(state): State<AppState>,
headers: HeaderMap,
request: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Result<Response, AppError> {
let auth_header = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
if auth_header != state.config.auth.auth_token {
return Err(AppError::Unauthorized("Invalid admin token".into()));
}
Ok(next.run(request).await)
}
/// Build all admin routes. Returns Router<AppState> (without calling .with_state).
pub fn routes(state: AppState) -> Router<AppState> {
let sites_routes = Router::new()
.route(
"/api/sites",
get(sites::list_sites).post(sites::create_site),
)
.route(
"/api/sites/{id}",
get(sites::get_site)
.put(sites::update_site)
.delete(sites::delete_site),
);
let accounts_routes = Router::new()
.route(
"/api/accounts",
get(accounts::list_accounts).post(accounts::create_account),
)
.route(
"/api/accounts/{id}",
get(accounts::get_account)
.put(accounts::update_account)
.delete(accounts::delete_account),
);
let tokens_routes = Router::new()
.route(
"/api/tokens",
get(tokens::list_tokens).post(tokens::create_token),
)
.route(
"/api/tokens/refresh-route",
get(tokens::refresh_route).post(tokens::refresh_route),
)
.route("/api/tokens/route-decision", get(tokens::route_decision))
.route(
"/api/tokens/{id}",
get(tokens::get_token)
.put(tokens::update_token)
.delete(tokens::delete_token),
);
let downstream_routes = Router::new()
.route(
"/api/downstream-api-keys",
get(downstream_keys::list_downstream_keys).post(downstream_keys::create_downstream_key),
)
.route(
"/api/downstream-api-keys/{id}",
put(downstream_keys::update_downstream_key)
.delete(downstream_keys::delete_downstream_key),
);
let settings_routes =
Router::new().route("/api/settings", get(get_settings).put(update_setting));
let users_routes = Router::new()
.route(
"/api/admin/users",
get(users::list_users).post(users::create_user),
)
.route(
"/api/admin/users/{id}",
get(users::get_user)
.put(users::update_user)
.delete(users::delete_user),
);
Router::new()
.merge(sites_routes)
.merge(accounts_routes)
.merge(tokens_routes)
.merge(downstream_routes)
.merge(settings_routes)
.merge(users_routes)
.merge(account_tokens::routes())
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
}
// ── Settings handlers ────────────────────────────────────────────────
/// GET /api/settings — return all settings as a key-value map.
async fn get_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Getting all settings");
let rows: Vec<Setting> =
sqlx::query_as::<_, Setting>("SELECT key, value FROM settings ORDER BY key")
.fetch_all(&*state.db)
.await?;
let map: std::collections::BTreeMap<String, Option<String>> =
rows.into_iter().map(|s| (s.key, s.value)).collect();
Ok(Json(json!({ "data": map })))
}
/// PUT /api/settings — upsert a setting by key.
/// Expects JSON body: { "key": "...", "value": "..." }
async fn update_setting(
State(state): State<AppState>,
Json(payload): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let key = payload
.get("key")
.and_then(|k| k.as_str())
.ok_or_else(|| AppError::BadRequest("key is required".into()))?;
let value = payload.get("value").and_then(|v| {
if v.is_null() {
None
} else {
v.as_str().map(|s| s.to_string())
}
});
info!("Upserting setting: {}", key);
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(key)
.bind(&value)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"data": {
"key": key,
"value": value
}
})))
}
+168
View File
@@ -0,0 +1,168 @@
use crate::{
db::models::site::Site,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct CreateSiteRequest {
pub name: String,
pub url: String,
pub platform: String,
pub status: Option<String>,
pub api_key: Option<String>,
pub proxy_url: Option<String>,
pub use_system_proxy: Option<bool>,
pub custom_headers: Option<String>,
pub global_weight: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateSiteRequest {
pub name: Option<String>,
pub url: Option<String>,
pub platform: Option<String>,
pub status: Option<String>,
pub api_key: Option<String>,
pub proxy_url: Option<String>,
pub use_system_proxy: Option<bool>,
pub custom_headers: Option<String>,
pub global_weight: Option<f64>,
}
/// GET /api/sites
pub async fn list_sites(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Listing all sites");
let sites: Vec<Site> =
sqlx::query_as::<_, Site>("SELECT * FROM sites ORDER BY sort_order, name")
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "data": sites })))
}
/// POST /api/sites
pub async fn create_site(
State(state): State<AppState>,
Json(payload): Json<CreateSiteRequest>,
) -> AppResult<(StatusCode, Json<Value>)> {
info!("Creating site: {}", payload.name);
if payload.name.trim().is_empty() {
return Err(AppError::BadRequest("name is required".into()));
}
if payload.url.trim().is_empty() {
return Err(AppError::BadRequest("url is required".into()));
}
if payload.platform.trim().is_empty() {
return Err(AppError::BadRequest("platform is required".into()));
}
let status = payload.status.unwrap_or_else(|| "active".to_string());
let result = sqlx::query(
"INSERT INTO sites (name, url, platform, status, api_key, proxy_url, use_system_proxy, custom_headers, global_weight) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(&payload.name)
.bind(&payload.url)
.bind(&payload.platform)
.bind(&status)
.bind(&payload.api_key)
.bind(&payload.proxy_url)
.bind(payload.use_system_proxy)
.bind(&payload.custom_headers)
.bind(payload.global_weight)
.execute(&*state.db)
.await?;
let site_id = result.last_insert_rowid();
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
.bind(site_id)
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "data": site }))))
}
/// GET /api/sites/{id}
pub async fn get_site(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Getting site: {}", id);
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
Ok(Json(json!({ "data": site })))
}
/// PUT /api/sites/{id}
pub async fn update_site(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<UpdateSiteRequest>,
) -> AppResult<Json<Value>> {
info!("Updating site: {}", id);
let existing: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
sqlx::query(
"UPDATE sites SET name = ?, url = ?, platform = ?, status = ?, api_key = ?, proxy_url = ?, use_system_proxy = ?, custom_headers = ?, global_weight = ?, updated_at = datetime('now') WHERE id = ?"
)
.bind(payload.name.unwrap_or(existing.name))
.bind(payload.url.unwrap_or(existing.url))
.bind(payload.platform.unwrap_or(existing.platform))
.bind(payload.status.unwrap_or(existing.status))
.bind(payload.api_key.or(existing.api_key))
.bind(payload.proxy_url.or(existing.proxy_url))
.bind(payload.use_system_proxy.or(existing.use_system_proxy))
.bind(payload.custom_headers.or(existing.custom_headers))
.bind(payload.global_weight.or(existing.global_weight))
.bind(id)
.execute(&*state.db)
.await?;
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": site })))
}
/// DELETE /api/sites/{id}
pub async fn delete_site(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Deleting site: {}", id);
let affected = sqlx::query("DELETE FROM sites WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("Site with id {} not found", id)));
}
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
}
+286
View File
@@ -0,0 +1,286 @@
use crate::{
db::models::account::AccountToken,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct ListTokensQuery {
pub account_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct CreateTokenRequest {
pub account_id: i64,
pub name: String,
pub token: String,
pub source: Option<String>,
pub token_group: Option<String>,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTokenRequest {
pub name: Option<String>,
pub token: Option<String>,
pub source: Option<String>,
pub token_group: Option<String>,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct RefreshRouteRequest {
pub model_pattern: Option<String>,
pub route_id: Option<i64>,
}
/// GET /api/tokens?account_id=
pub async fn list_tokens(
State(state): State<AppState>,
Query(query): Query<ListTokensQuery>,
) -> AppResult<Json<Value>> {
info!("Listing tokens, account_id filter: {:?}", query.account_id);
let tokens: Vec<AccountToken> = if let Some(account_id) = query.account_id {
sqlx::query_as::<_, AccountToken>(
"SELECT * FROM account_tokens WHERE account_id = ? ORDER BY id",
)
.bind(account_id)
.fetch_all(&*state.db)
.await?
} else {
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens ORDER BY account_id, id")
.fetch_all(&*state.db)
.await?
};
Ok(Json(json!({ "data": tokens })))
}
/// POST /api/tokens
pub async fn create_token(
State(state): State<AppState>,
Json(payload): Json<CreateTokenRequest>,
) -> AppResult<(StatusCode, Json<Value>)> {
info!("Creating token for account_id: {}", payload.account_id);
// Validate
if payload.name.trim().is_empty() {
return Err(AppError::BadRequest("name is required".into()));
}
if payload.token.trim().is_empty() {
return Err(AppError::BadRequest("token is required".into()));
}
// Verify account exists
let account_exists: Option<(i64,)> = sqlx::query_as("SELECT id FROM accounts WHERE id = ?")
.bind(payload.account_id)
.fetch_optional(&*state.db)
.await?;
if account_exists.is_none() {
return Err(AppError::BadRequest(format!(
"Account with id {} not found",
payload.account_id
)));
}
let source = payload.source.unwrap_or_else(|| "manual".to_string());
let enabled = payload.enabled.unwrap_or(true);
let is_default = payload.is_default.unwrap_or(false);
let result = sqlx::query(
"INSERT INTO account_tokens (account_id, name, token, source, token_group, enabled, is_default) VALUES (?, ?, ?, ?, ?, ?, ?)"
)
.bind(payload.account_id)
.bind(&payload.name)
.bind(&payload.token)
.bind(&source)
.bind(&payload.token_group)
.bind(enabled)
.bind(is_default)
.execute(&*state.db)
.await?;
let token_id = result.last_insert_rowid();
let token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(token_id)
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "data": token }))))
}
/// GET /api/tokens/{id}
pub async fn get_token(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Getting token: {}", id);
let token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Token with id {} not found", id)))?;
Ok(Json(json!({ "data": token })))
}
/// PUT /api/tokens/{id}
pub async fn update_token(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<UpdateTokenRequest>,
) -> AppResult<Json<Value>> {
info!("Updating token: {}", id);
let existing: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Token with id {} not found", id)))?;
sqlx::query(
"UPDATE account_tokens SET name = ?, token = ?, source = ?, token_group = ?, enabled = ?, is_default = ?, updated_at = datetime('now') WHERE id = ?"
)
.bind(payload.name.unwrap_or(existing.name))
.bind(payload.token.as_ref().unwrap_or(&existing.token))
.bind(payload.source.unwrap_or(existing.source.unwrap_or_else(|| "manual".to_string())))
.bind(payload.token_group.or(existing.token_group))
.bind(payload.enabled.unwrap_or(existing.enabled.unwrap_or(true)))
.bind(payload.is_default.unwrap_or(existing.is_default.unwrap_or(false)))
.bind(id)
.execute(&*state.db)
.await?;
let token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": token })))
}
/// DELETE /api/tokens/{id}
pub async fn delete_token(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Deleting token: {}", id);
let affected = sqlx::query("DELETE FROM account_tokens WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Token with id {} not found",
id
)));
}
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
}
/// POST /api/tokens/refresh-route
pub async fn refresh_route(
State(state): State<AppState>,
Json(payload): Json<RefreshRouteRequest>,
) -> AppResult<Json<Value>> {
info!("Refreshing route decision");
if let Some(route_id) = payload.route_id {
sqlx::query(
"UPDATE token_routes SET decision_refreshed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?"
)
.bind(route_id)
.execute(&*state.db)
.await?;
let route: Option<crate::db::models::token_route::TokenRoute> =
sqlx::query_as("SELECT * FROM token_routes WHERE id = ?")
.bind(route_id)
.fetch_optional(&*state.db)
.await?;
return Ok(Json(
json!({ "data": { "refreshed": true, "route": route } }),
));
}
if let Some(ref model_pattern) = payload.model_pattern {
let result = sqlx::query(
"UPDATE token_routes SET decision_refreshed_at = datetime('now'), updated_at = datetime('now') WHERE model_pattern LIKE ?"
)
.bind(format!("%{}%", model_pattern))
.execute(&*state.db)
.await?;
return Ok(Json(json!({
"data": {
"refreshed": true,
"affected_routes": result.rows_affected()
}
})));
}
// Refresh all routes
let result = sqlx::query(
"UPDATE token_routes SET decision_refreshed_at = datetime('now'), updated_at = datetime('now')"
)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"data": {
"refreshed": true,
"affected_routes": result.rows_affected()
}
})))
}
/// GET /api/tokens/route-decision
pub async fn route_decision(
State(state): State<AppState>,
Query(query): Query<RefreshRouteRequest>,
) -> AppResult<Json<Value>> {
info!("Getting route decisions");
let routes: Vec<crate::db::models::token_route::TokenRoute> =
if let Some(route_id) = query.route_id {
sqlx::query_as("SELECT * FROM token_routes WHERE id = ? AND enabled = 1")
.bind(route_id)
.fetch_optional(&*state.db)
.await?
.map(|r| vec![r])
.unwrap_or_default()
} else if let Some(ref model_pattern) = query.model_pattern {
sqlx::query_as("SELECT * FROM token_routes WHERE model_pattern LIKE ? AND enabled = 1")
.bind(format!("%{}%", model_pattern))
.fetch_all(&*state.db)
.await?
} else {
sqlx::query_as("SELECT * FROM token_routes WHERE enabled = 1 ORDER BY model_pattern")
.fetch_all(&*state.db)
.await?
};
Ok(Json(json!({ "data": routes })))
}
+191
View File
@@ -0,0 +1,191 @@
// Admin user management — CRUD for platform users
use crate::{
auth::password::hash_password,
db::models::user::User,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub email: String,
pub password: String,
pub role: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateUserRequest {
pub username: Option<String>,
pub email: Option<String>,
pub password: Option<String>,
pub role: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: i64,
pub username: String,
pub email: String,
pub role: String,
pub status: String,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl From<User> for UserResponse {
fn from(u: User) -> Self {
Self {
id: u.id,
username: u.username,
email: u.email,
role: u.role,
status: u.status,
created_at: u.created_at,
updated_at: u.updated_at,
}
}
}
/// GET /api/admin/users
pub async fn list_users(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Listing admin users");
let users: Vec<User> = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id")
.fetch_all(&*state.db)
.await?;
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect();
Ok(Json(json!({ "data": items })))
}
/// GET /api/admin/users/{id}
pub async fn get_user(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Getting user: {}", id);
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
Ok(Json(json!({ "data": UserResponse::from(user) })))
}
/// POST /api/admin/users
pub async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserRequest>,
) -> AppResult<(axum::http::StatusCode, Json<Value>)> {
info!("Creating user: {}", payload.username);
if payload.username.trim().is_empty() {
return Err(AppError::BadRequest("username is required".into()));
}
if payload.email.trim().is_empty() {
return Err(AppError::BadRequest("email is required".into()));
}
if payload.password.len() < 6 {
return Err(AppError::BadRequest(
"password must be at least 6 characters".into(),
));
}
let password_hash = hash_password(&payload.password)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
let role = payload.role.unwrap_or_else(|| "user".to_string());
let result =
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.bind(&role)
.execute(&*state.db)
.await?;
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok((
axum::http::StatusCode::CREATED,
Json(json!({ "data": UserResponse::from(user) })),
))
}
/// PUT /api/admin/users/{id}
pub async fn update_user(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<UpdateUserRequest>,
) -> AppResult<Json<Value>> {
info!("Updating user: {}", id);
let existing: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
let password_hash = if let Some(ref pw) = payload.password {
if pw.len() < 6 {
return Err(AppError::BadRequest(
"password must be at least 6 characters".into(),
));
}
Some(
hash_password(pw)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?,
)
} else {
None
};
sqlx::query(
"UPDATE users SET username = ?, email = ?, role = ?, status = ?, password_hash = COALESCE(?, password_hash), updated_at = datetime('now') WHERE id = ?"
)
.bind(payload.username.unwrap_or(existing.username))
.bind(payload.email.unwrap_or(existing.email))
.bind(payload.role.unwrap_or(existing.role))
.bind(payload.status.unwrap_or(existing.status))
.bind(password_hash)
.bind(id)
.execute(&*state.db)
.await?;
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": UserResponse::from(user) })))
}
/// DELETE /api/admin/users/{id}
pub async fn delete_user(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Deleting user: {}", id);
let affected = sqlx::query("DELETE FROM users WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("User {} not found", id)));
}
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
}
+334
View File
@@ -0,0 +1,334 @@
// User authentication routes — register, login, profile
// These are public (no admin token required, uses JWT)
use crate::{
auth::{jwt::generate_jwt, password::verify_password},
db::models::user::User,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::HeaderMap,
routing::{get, patch, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::info;
// ── Request / Response types ─────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub token: String,
pub user: UserProfile,
}
#[derive(Debug, Serialize)]
pub struct UserProfile {
pub id: i64,
pub username: String,
pub email: String,
pub role: String,
}
// ── Route builder ────────────────────────────────────────────────────
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/auth/register", post(register))
.route("/api/auth/login", post(login))
.route("/api/auth/me", get(me))
.route("/api/users/me", patch(update_me))
.route("/api/users/me/password", post(change_password))
.route("/api/users/{id}", get(get_user))
}
// ── Handlers ─────────────────────────────────────────────────────────
/// POST /api/auth/register
async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> AppResult<Json<Value>> {
info!("Registering user: {}", payload.email);
if payload.email.trim().is_empty() || payload.username.trim().is_empty() {
return Err(AppError::BadRequest(
"email and username are required".into(),
));
}
if payload.password.len() < 6 {
return Err(AppError::BadRequest(
"password must be at least 6 characters".into(),
));
}
// Check if email already exists
let existing: Option<User> = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
.bind(&payload.email)
.fetch_optional(&*state.db)
.await?;
if existing.is_some() {
return Err(AppError::BadRequest("Email already registered".into()));
}
let password_hash = crate::auth::password::hash_password(&payload.password)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
let result = sqlx::query(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')",
)
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.execute(&*state.db)
.await?;
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
let token = generate_jwt(
&state.config.auth.jwt_secret,
&user.id.to_string(),
&user.role,
state.config.auth.jwt_expiry_hours,
)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
Ok(Json(json!({
"data": {
"token": token,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
}
}
})))
}
/// POST /api/auth/login
async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> AppResult<Json<Value>> {
info!("Login: {}", payload.email);
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
.bind(&payload.email)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::Unauthorized("Invalid email or password".into()))?;
if user.status != "active" {
return Err(AppError::Forbidden("Account is disabled".into()));
}
let valid = verify_password(&payload.password, &user.password_hash)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
if !valid {
return Err(AppError::Unauthorized("Invalid email or password".into()));
}
let token = generate_jwt(
&state.config.auth.jwt_secret,
&user.id.to_string(),
&user.role,
state.config.auth.jwt_expiry_hours,
)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
Ok(Json(json!({
"data": {
"token": token,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
}
}
})))
}
/// GET /api/auth/me — returns current user profile from JWT
async fn me(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(&claims.sub.parse::<i64>().unwrap_or(0))
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
Ok(Json(json!({
"data": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
}
})))
}
/// PATCH /api/users/me — update current user profile
async fn update_me(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let (user_id, _role) = extract_jwt(&state, &headers)?;
let username = body.get("username").and_then(|v| v.as_str());
if let Some(name) = username {
if !name.trim().is_empty() {
sqlx::query("UPDATE users SET username = ? WHERE id = ?")
.bind(name.trim())
.bind(user_id)
.execute(&*state.db)
.await?;
}
}
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
}
})))
}
/// POST /api/users/me/password — change current user password
async fn change_password(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let (user_id, _role) = extract_jwt(&state, &headers)?;
let old_pw = body
.get("oldPassword")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("oldPassword is required".into()))?;
let new_pw = body
.get("newPassword")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("newPassword is required".into()))?;
if new_pw.len() < 6 {
return Err(AppError::BadRequest(
"new password must be at least 6 characters".into(),
));
}
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
let valid = crate::auth::password::verify_password(old_pw, &user.password_hash)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
if !valid {
return Err(AppError::Unauthorized(
"Current password is incorrect".into(),
));
}
let new_hash = crate::auth::password::hash_password(new_pw)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
.bind(&new_hash)
.bind(user_id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// GET /api/users/:id — get user profile (self or admin)
async fn get_user(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
let (user_id, role) = extract_jwt(&state, &headers)?;
if user_id != id && role != "admin" {
return Err(AppError::Forbidden("Forbidden".into()));
}
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
Ok(Json(json!({
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
"created_at": user.created_at,
}
})))
}
// ── Inline JWT extract helper for this module ──────────────────────────
fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let uid: i64 = claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
Ok((uid, claims.role))
}
+220
View File
@@ -0,0 +1,220 @@
// Check-in routes — daily account check-in management
use crate::{
db::models::account::Account,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct CheckinLogQuery {
limit: Option<i64>,
offset: Option<i64>,
account_id: Option<i64>,
status: Option<String>,
}
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct CheckinLog {
pub id: i64,
pub account_id: i64,
pub status: String,
pub message: Option<String>,
pub reward: Option<String>,
pub created_at: Option<String>,
}
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct CheckinStats {
pub account_id: i64,
pub site_name: String,
pub username: String,
pub status: String,
pub last_checkin: Option<String>,
pub checkin_enabled: bool,
pub balance: Option<f64>,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/checkin", get(list_checkin_logs))
.route("/api/checkin/stats", get(checkin_stats))
.route("/api/checkin/accounts", get(list_checkin_accounts))
.route(
"/api/checkin/accounts/{id}/toggle",
post(toggle_checkin_account),
)
.route("/api/checkin/run", post(run_checkin))
}
/// GET /api/checkin — list checkin logs
async fn list_checkin_logs(
State(state): State<AppState>,
Query(q): Query<CheckinLogQuery>,
) -> AppResult<Json<Value>> {
info!("Listing checkin logs");
let limit = q.limit.unwrap_or(50).clamp(1, 200);
let offset = q.offset.unwrap_or(0).max(0);
let mut sql = "SELECT * FROM checkin_logs WHERE 1=1".to_string();
let mut params: Vec<String> = vec![];
if let Some(aid) = q.account_id {
sql.push_str(&format!(" AND account_id = {}", aid));
}
if let Some(ref s) = q.status {
sql.push_str(" AND status = ?");
params.push(s.clone());
}
sql.push_str(" ORDER BY created_at DESC");
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
let mut query = sqlx::query_as::<_, CheckinLog>(&sql);
for p in &params {
query = query.bind(p);
}
let logs: Vec<CheckinLog> = query.fetch_all(&*state.db).await?;
Ok(Json(json!({ "data": logs })))
}
/// GET /api/checkin/stats — checkin statistics
async fn checkin_stats(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Checkin stats");
let rows: Vec<CheckinStats> = sqlx::query_as::<_, CheckinStats>(
"SELECT a.id as account_id, s.name as site_name, a.username, a.status,
a.last_checkin_at as last_checkin,
COALESCE(a.checkin_enabled, 0) as checkin_enabled,
a.balance
FROM accounts a
JOIN sites s ON s.id = a.site_id
WHERE a.status = 'active'
ORDER BY a.last_checkin_at ASC",
)
.fetch_all(&*state.db)
.await?;
let total = rows.len();
let checked_today = rows
.iter()
.filter(|r| {
r.last_checkin
.as_deref()
.map_or(false, |d| d.starts_with(&today_date()))
})
.count();
Ok(Json(json!({
"total_accounts": total,
"checked_today": checked_today,
"pending": total - checked_today,
"accounts": rows,
})))
}
/// GET /api/checkin/accounts — list accounts with checkin info
async fn list_checkin_accounts(State(state): State<AppState>) -> AppResult<Json<Value>> {
let accounts: Vec<Value> =
sqlx::query_as::<_, Account>("SELECT * FROM accounts ORDER BY site_id, username")
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|a| {
json!({
"id": a.id,
"site_id": a.site_id,
"username": a.username,
"status": a.status,
"checkin_enabled": a.checkin_enabled,
"last_checkin_at": a.last_checkin_at,
"balance": a.balance,
})
})
.collect();
Ok(Json(json!({ "data": accounts })))
}
/// POST /api/checkin/accounts/{id}/toggle — enable/disable checkin
async fn toggle_checkin_account(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Toggling checkin for account: {}", id);
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
let new_value = !account.checkin_enabled.unwrap_or(true);
sqlx::query(
"UPDATE accounts SET checkin_enabled = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(new_value)
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(
json!({ "success": true, "checkin_enabled": new_value }),
))
}
/// POST /api/checkin/run — trigger checkin for all enabled accounts
async fn run_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Running checkin for all enabled accounts");
let accounts: Vec<Account> = sqlx::query_as::<_, Account>(
"SELECT * FROM accounts WHERE status = 'active' AND COALESCE(checkin_enabled, 1) = 1",
)
.fetch_all(&*state.db)
.await?;
let mut results: Vec<Value> = vec![];
for account in &accounts {
// Simple balance bonus checkin (matches Node.js basic behavior)
let bonus = 0.01; // $0.01 per day
sqlx::query("UPDATE accounts SET balance = COALESCE(balance, 0) + ?, last_checkin_at = datetime('now'), updated_at = datetime('now') WHERE id = ?")
.bind(bonus)
.bind(account.id)
.execute(&*state.db)
.await?;
sqlx::query("INSERT INTO checkin_logs (account_id, status, message, reward) VALUES (?, 'success', 'Daily checkin completed', ?)")
.bind(account.id)
.bind(format!("+${:.4}", bonus))
.execute(&*state.db)
.await?;
results.push(json!({
"account_id": account.id,
"username": account.username,
"status": "success",
"reward": bonus,
}));
}
Ok(Json(json!({
"success": true,
"processed": results.len(),
"results": results,
})))
}
/// Get today's date as YYYY-MM-DD
fn today_date() -> String {
use chrono::Utc;
Utc::now().format("%Y-%m-%d").to_string()
}
+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())
}
+658
View File
@@ -0,0 +1,658 @@
use crate::AppState;
use axum::{
extract::State,
http::HeaderMap,
response::sse::{Event, Sse},
routing::post,
Json, Router,
};
use futures::stream::Stream;
use regex::Regex;
use reqwest::Client;
use serde_json::{json, Value};
use std::convert::Infallible;
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
use tracing::info;
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮用户通过 AI 制作视频。
## 规则
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定
2. 用户确认后(说"开始制作"或"生成"),再分步输出结构化内容
3. **分两步走:第一步输出角色+场景+TTS,第二步输出 WORKFLOW**(不要一次全出)
## 结构化标记格式
严格按照以下格式输出,系统会自动解析并生成图片/TTS:
【CHARACTER】
name: 林澈
description: 女宇航员,30岁,冷静坚毅,短发收在头盔内
prompt_front: cinematic full body front view of a female astronaut, white EVA suit, photorealistic
prompt_back: cinematic full body back view of a female astronaut, life support backpack
prompt_left: cinematic full body left side view of a female astronaut, side utility pouches
prompt_right: cinematic full body right side view of a female astronaut, helmet visor reflection
【/CHARACTER】
【SCENE】
id: 1
description: 飞船驾驶舱内景
prompt: interior of a sci-fi spacecraft cockpit, holographic displays, cold blue lighting, cinematic
【/SCENE】
【TTS】
character: 林澈
text: 我们准备出舱检查
emotion: 平静
【/TTS】
【WORKFLOW】
```json
{"comfyui_workflow": {}}
```
【/WORKFLOW】
## 关键要求
- prompt_* 用英文,同时包含主体 + 视角 + 细节 + 画质标签
- 不要一次输出太多,先输角色和场景,下次再输工作流
- 如果没有用户明确要求,切勿自作主张输出全部内容
"#;
fn upstream_base() -> String {
std::env::var("BOOSAPI_UPSTREAM_BASE")
.unwrap_or_else(|_| "https://api.boos.lat/v1".to_string())
.trim_end_matches('/')
.to_string()
}
fn upstream_key() -> String {
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
}
pub fn routes() -> Router<AppState> {
Router::new().route("/api/comfyui-agent/chat", post(chat_handler))
}
#[derive(Debug, Default)]
struct ParsedSections {
characters: Vec<CharacterSection>,
scenes: Vec<SceneSection>,
tts_list: Vec<TtsSection>,
workflow_json: Option<String>,
text_content: String,
}
#[derive(Debug, Default)]
struct CharacterSection {
name: String,
description: String,
prompt_front: String,
prompt_back: String,
prompt_left: String,
prompt_right: String,
}
#[derive(Debug)]
struct SceneSection {
id: u32,
description: String,
prompt: String,
}
#[derive(Debug)]
struct TtsSection {
character: String,
text: String,
emotion: String,
}
fn parse_sections(full_text: &str) -> ParsedSections {
let mut sections = ParsedSections::default();
sections.text_content = full_text.to_string();
// Parse CHARACTER blocks
let char_re = Regex::new(r"(?s)【CHARACTER】\s*(.*?)【/CHARACTER】").unwrap();
for cap in char_re.captures_iter(full_text) {
let block = cap.get(1).unwrap().as_str();
let mut char_section = CharacterSection::default();
for line in block.lines() {
let line = line.trim();
let line_lower = line.to_lowercase();
if let Some(val) = line.strip_prefix("name:").or_else(|| line.strip_prefix("name :")) {
char_section.name = val.trim().to_string();
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
char_section.description = val.trim().to_string();
} else if line_lower.starts_with("prompt_front:") || line_lower.starts_with("prompt_front :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
char_section.prompt_front = val.to_string();
} else if line_lower.starts_with("prompt_back:") || line_lower.starts_with("prompt_back :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
char_section.prompt_back = val.to_string();
} else if line_lower.starts_with("prompt_left:") || line_lower.starts_with("prompt_left :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
char_section.prompt_left = val.to_string();
} else if line_lower.starts_with("prompt_right:") || line_lower.starts_with("prompt_right :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
char_section.prompt_right = val.to_string();
}
}
if !char_section.name.is_empty() {
sections.characters.push(char_section);
}
}
// Parse SCENE blocks
let scene_re = Regex::new(r"(?s)【SCENE】\s*(.*?)【/SCENE】").unwrap();
for cap in scene_re.captures_iter(full_text) {
let block = cap.get(1).unwrap().as_str();
let mut scene = SceneSection {
id: 0,
description: String::new(),
prompt: String::new(),
};
for line in block.lines() {
let line = line.trim();
let line_lower = line.to_lowercase();
if line_lower.starts_with("id:") || line_lower.starts_with("id :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
scene.id = val.parse().unwrap_or(0);
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
scene.description = val.trim().to_string();
} else if line_lower.starts_with("prompt:") || line_lower.starts_with("prompt :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
scene.prompt = val.to_string();
}
}
if !scene.prompt.is_empty() {
sections.scenes.push(scene);
}
}
// Parse TTS blocks
let tts_re = Regex::new(r"(?s)【TTS】\s*(.*?)【/TTS】").unwrap();
for cap in tts_re.captures_iter(full_text) {
let block = cap.get(1).unwrap().as_str();
let mut tts = TtsSection {
character: String::new(),
text: String::new(),
emotion: String::new(),
};
for line in block.lines() {
let line = line.trim();
let line_lower = line.to_lowercase();
if line_lower.starts_with("character:") || line_lower.starts_with("character :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
tts.character = val.to_string();
} else if line_lower.starts_with("text:") || line_lower.starts_with("text :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
tts.text = val.to_string();
} else if line_lower.starts_with("emotion:") || line_lower.starts_with("emotion :") {
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
tts.emotion = val.to_string();
}
}
if !tts.text.is_empty() {
sections.tts_list.push(tts);
}
}
// Parse WORKFLOW blocks
let wf_re = Regex::new(r"(?s)【WORKFLOW】\s*```(?:json)?\s*(.*?)```\s*【/WORKFLOW】").unwrap();
if let Some(cap) = wf_re.captures(full_text) {
let wf = cap.get(1).unwrap().as_str().trim().to_string();
sections.workflow_json = Some(wf);
}
sections
}
async fn call_image_gen(prompt: &str) -> Result<String, String> {
let client = Client::new();
let url = format!("{}/images/generations", upstream_base());
let resp = client
.post(&url)
.bearer_auth(upstream_key())
.json(&json!({
"model": "gpt-image-2",
"prompt": prompt,
"n": 1,
"size": "1024x1024"
}))
.send()
.await
.map_err(|e| format!("image request failed: {}", e))?;
let status = resp.status();
let data: Value = resp
.json()
.await
.map_err(|e| format!("image response parse failed: {}", e))?;
if !status.is_success() {
return Err(format!("image upstream returned {}: {}", status, data));
}
Ok(data
.get("data")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("url"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
async fn call_tts(text: &str, voice: &str) -> Result<Vec<u8>, String> {
let client = Client::new();
let url = format!("{}/audio/speech", upstream_base());
let resp = client
.post(&url)
.bearer_auth(upstream_key())
.json(&json!({
"model": "tts-1",
"input": text,
"voice": voice
}))
.send()
.await
.map_err(|e| format!("TTS request failed: {}", e))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("TTS upstream returned {}: {}", status, body));
}
let bytes = resp
.bytes()
.await
.map_err(|e| format!("TTS response read failed: {}", e))?;
Ok(bytes.to_vec())
}
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
tx.send(Ok(Event::default().data(payload.to_string()))).is_ok()
}
async fn chat_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (axum::http::StatusCode, Json<Value>)>
{
let auth_header = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let token = auth_header
.strip_prefix("Bearer ")
.or_else(|| auth_header.strip_prefix("bearer "))
.unwrap_or_default();
if token != state.config.auth.auth_token {
return Err((
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
));
}
let messages = body
.get("messages")
.and_then(Value::as_array)
.ok_or_else(|| {
(
axum::http::StatusCode::BAD_REQUEST,
Json(json!({ "error": "messages is required" })),
)
})?;
for message in messages {
if message.get("role").and_then(Value::as_str).is_none()
|| message.get("content").and_then(Value::as_str).is_none()
{
return Err((
axum::http::StatusCode::BAD_REQUEST,
Json(json!({ "error": "messages is required" })),
));
}
}
let messages = messages.clone();
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
tokio::spawn(async move {
let upstream_url = format!("{}/chat/completions", upstream_base().trim_end_matches('/'));
let mut upstream_messages = Vec::with_capacity(messages.len() + 1);
upstream_messages.push(json!({
"role": "system",
"content": SYSTEM_PROMPT,
}));
upstream_messages.extend(messages);
let client = Client::new();
info!("comfyui-agent: sending chat request to upstream");
let response = match client
.post(&upstream_url)
.bearer_auth(upstream_key())
.json(&json!({
"model": "gpt-5.5",
"messages": upstream_messages,
"temperature": 0.7,
"stream": true,
}))
.send()
.await
{
Ok(response) => response,
Err(e) => {
send_sse(
&tx,
json!({
"type": "text",
"content": format!("服务暂时不可用,请稍后重试。({})", e),
}),
);
send_sse(&tx, json!({ "type": "done" }));
return;
}
};
if !response.status().is_success() {
send_sse(
&tx,
json!({
"type": "text",
"content": "服务暂时不可用,请稍后重试",
}),
);
send_sse(&tx, json!({ "type": "done" }));
return;
}
let mut stream = response.bytes_stream();
let mut buffer: Vec<u8> = Vec::new();
let mut accumulated_text = String::new();
// Phase 1: Stream the LLM text response
loop {
let chunk = tokio::select! {
_ = tx.closed() => {
return;
}
chunk = stream.next() => chunk,
};
let Some(chunk) = chunk else {
break;
};
let chunk = match chunk {
Ok(chunk) => chunk,
Err(error) => {
send_sse(
&tx,
json!({
"type": "text",
"content": format!("\n\n[错误: {}]", error),
}),
);
break;
}
};
buffer.extend_from_slice(&chunk);
while let Some(pos) = buffer.iter().position(|byte| *byte == b'\n') {
let mut line_bytes: Vec<u8> = buffer.drain(..=pos).collect();
if line_bytes.last() == Some(&b'\n') {
line_bytes.pop();
}
if line_bytes.last() == Some(&b'\r') {
line_bytes.pop();
}
let line = String::from_utf8_lossy(&line_bytes);
let line = line.trim().to_string();
if !line.starts_with("data: ") {
continue;
}
let data = line.trim_start_matches("data: ").trim().to_string();
if data == "[DONE]" {
continue;
}
let Ok(parsed) = serde_json::from_str::<Value>(&data) else {
continue;
};
let content = parsed
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
.and_then(|delta| delta.get("content"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !content.is_empty() {
accumulated_text.push_str(&content);
if !send_sse(
&tx,
json!({
"type": "text",
"content": content,
}),
) {
return;
}
}
}
}
// Phase 2: Parse structured sections from accumulated text
info!("comfyui-agent: text streaming complete, parsing sections");
let sections = parse_sections(&accumulated_text);
let has_structured_data = !sections.characters.is_empty()
|| !sections.scenes.is_empty()
|| !sections.tts_list.is_empty()
|| sections.workflow_json.is_some();
if !has_structured_data {
send_sse(&tx, json!({ "type": "done" }));
return;
}
// Send a processing indicator
send_sse(
&tx,
json!({
"type": "text",
"content": "\n\n🔄 正在处理结构化数据..."
}),
);
// Generate images for each character (4 angles each)
for (char_idx, character) in sections.characters.iter().enumerate() {
// Send character_prompts event
send_sse(
&tx,
json!({
"type": "character_prompts",
"character": character.name,
}),
);
let angles = [
("正面", &character.prompt_front, "front"),
("背面", &character.prompt_back, "back"),
("左侧", &character.prompt_left, "left"),
("右侧", &character.prompt_right, "right"),
];
for (angle_label, prompt, angle_key) in &angles {
if prompt.is_empty() {
continue;
}
info!(
"comfyui-agent: generating image for {} - {}",
character.name, angle_label
);
// Add a short delay between image gen requests to avoid rate limiting
if char_idx > 0 || *angle_key != "front" {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
match call_image_gen(prompt).await {
Ok(url) if !url.is_empty() => {
send_sse(
&tx,
json!({
"type": "image",
"url": url,
"character": character.name,
"angle": angle_key,
}),
);
}
_ => {
send_sse(
&tx,
json!({
"type": "text",
"content": format!("\n⚠️ {} [{}] 图片生成失败,请重试", character.name, angle_label),
}),
);
}
}
}
}
// Generate images for each scene
for scene in &sections.scenes {
info!("comfyui-agent: generating scene image for scene {}", scene.id);
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
match call_image_gen(&scene.prompt).await {
Ok(url) if !url.is_empty() => {
send_sse(
&tx,
json!({
"type": "scene",
"id": scene.id,
"description": scene.description,
"prompt": scene.prompt,
"url": url,
}),
);
}
_ => {
// Still send scene info even without image
send_sse(
&tx,
json!({
"type": "scene",
"id": scene.id,
"description": scene.description,
"prompt": scene.prompt,
}),
);
}
}
}
// Generate TTS for each dialogue
for tts in &sections.tts_list {
info!("comfyui-agent: generating TTS for {}", tts.character);
// Choose voice based on emotion
let voice = match tts.emotion.as_str() {
"激动" | "愤怒" => "nova",
"悲伤" | "恐惧" => "shimmer",
_ => "alloy",
};
match call_tts(&tts.text, voice).await {
Ok(audio_bytes) => {
// For SSE, we'll send a data URL for small audio, or a placeholder
// Since SSE can't easily send binary, we store to file and send URL
let audio_path = format!("/tmp/comfyui_tts_{}_{}.mp3",
tts.character.replace(' ', "_"),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
);
if let Ok(()) = tokio::fs::write(&audio_path, &audio_bytes).await {
send_sse(
&tx,
json!({
"type": "tts",
"character": tts.character,
"text": tts.text,
"url": format!("/audio/{}", audio_path.trim_start_matches("/tmp/comfyui_tts_")),
}),
);
}
}
Err(e) => {
info!("comfyui-agent: TTS generation failed: {}", e);
send_sse(
&tx,
json!({
"type": "text",
"content": format!("\n⚠️ {} 的语音生成失败", tts.character),
}),
);
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
// Send ComfyUI workflow if present
if let Some(wf_json) = &sections.workflow_json {
info!("comfyui-agent: sending workflow JSON");
send_sse(
&tx,
json!({
"type": "comfyui_workflow",
"content": wf_json,
}),
);
}
// Signal done with summary
let mut summary = String::from("\n\n✅ **处理完成!**");
if !sections.characters.is_empty() {
summary.push_str(&format!("\n- 角色: {} 个", sections.characters.len()));
}
if !sections.scenes.is_empty() {
summary.push_str(&format!("\n- 场景: {} 个", sections.scenes.len()));
}
if !sections.tts_list.is_empty() {
summary.push_str(&format!("\n- TTS: {} 段", sections.tts_list.len()));
}
if sections.workflow_json.is_some() {
summary.push_str("\n- ComfyUI 工作流: ✅ 已生成");
}
send_sse(&tx, json!({ "type": "text", "content": summary }));
send_sse(&tx, json!({ "type": "done" }));
});
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
}
+106
View File
@@ -0,0 +1,106 @@
// Events routes — system event notifications
use crate::{
db::models::event::Event,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
routing::{delete, get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct ListEventsQuery {
limit: Option<i64>,
offset: Option<i64>,
#[serde(rename = "type")]
filter_type: Option<String>,
read: Option<String>, // "true" | "false"
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/events", get(list_events).delete(clear_events))
.route("/api/events/count", get(unread_count))
.route("/api/events/{id}/read", post(mark_read))
.route("/api/events/read-all", post(mark_all_read))
}
/// GET /api/events
async fn list_events(
State(state): State<AppState>,
Query(q): Query<ListEventsQuery>,
) -> AppResult<Json<Value>> {
info!("Listing events");
let limit = q.limit.unwrap_or(30).clamp(1, 500);
let offset = q.offset.unwrap_or(0).max(0);
let mut sql = "SELECT * FROM events WHERE 1=1".to_string();
let mut bind_idx = 0;
let mut params: Vec<String> = vec![];
if let Some(ref t) = q.filter_type {
bind_idx += 1;
sql.push_str(&format!(" AND type = ?{}", bind_idx));
params.push(t.clone());
}
if let Some(ref r) = q.read {
if r == "true" {
sql.push_str(" AND read = 1");
} else if r == "false" {
sql.push_str(" AND read = 0");
}
}
sql.push_str(" ORDER BY created_at DESC");
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
let mut query = sqlx::query_as::<_, Event>(&sql);
for p in &params {
query = query.bind(p);
}
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
Ok(Json(json!({ "data": events })))
}
/// GET /api/events/count — unread count
async fn unread_count(State(state): State<AppState>) -> AppResult<Json<Value>> {
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM events WHERE read = 0")
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "count": count.0 })))
}
/// POST /api/events/{id}/read
async fn mark_read(State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
info!("Marking event {} as read", id);
sqlx::query("UPDATE events SET read = 1 WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// POST /api/events/read-all
async fn mark_all_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Marking all events as read");
sqlx::query("UPDATE events SET read = 1 WHERE read = 0")
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// DELETE /api/events
async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Clearing all events");
sqlx::query("DELETE FROM events")
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
+14
View File
@@ -0,0 +1,14 @@
use crate::{error::AppResult, AppState};
use axum::{extract::State, Json};
use serde_json::{json, Value};
pub async fn health_check(State(state): State<AppState>) -> AppResult<Json<Value>> {
// Simple DB ping
sqlx::query("SELECT 1").execute(&*state.db).await?;
Ok(Json(json!({
"status": "ok",
"database": "ok",
"version": env!("CARGO_PKG_VERSION"),
})))
}
+94
View File
@@ -0,0 +1,94 @@
use crate::AppState;
use axum::{
body::Body,
http::{Request, StatusCode, Uri},
response::{Html, Response},
routing::get,
Router,
};
use std::convert::Infallible;
use tower::service_fn;
use tower_http::services::ServeDir;
mod admin;
mod auth;
mod checkin;
mod comfyui;
mod comfyui_agent;
mod events;
mod health;
mod monitor;
mod oauth;
mod organizations;
mod proxy;
mod search;
mod site_announcements;
mod stats;
mod tasks;
mod update_center;
mod user;
mod user_api_keys;
mod video_agent;
pub fn build_router(state: AppState) -> Router {
let web_dir = std::env::var("BOOSAPI_WEB_DIR")
.unwrap_or_else(|_| "dist/web".to_string());
// Build the API router
let api_routes = Router::new()
.route("/healthz", get(health::health_check))
.merge(admin::routes(state.clone()))
.merge(proxy::routes())
.merge(comfyui_agent::routes())
.merge(comfyui::routes())
.merge(auth::routes())
.merge(events::routes())
.merge(checkin::routes())
.merge(monitor::routes())
.merge(oauth::routes())
.merge(organizations::routes())
.merge(search::routes())
.merge(site_announcements::routes())
.merge(stats::routes())
.merge(tasks::routes())
.merge(update_center::routes())
.merge(user::routes())
.merge(user_api_keys::routes())
.merge(video_agent::routes())
.with_state(state);
// Static file serving with SPA fallback
let web_dir_owned = web_dir.clone();
let static_service = ServeDir::new(&web_dir)
.append_index_html_on_directories(true)
.fallback(service_fn(move |req: Request<Body>| {
let web_dir = web_dir_owned.clone();
async move {
let index_path = format!("{}/index.html", web_dir);
match tokio::fs::read_to_string(&index_path).await {
Ok(html) => {
Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/html; charset=utf-8")
.body(Body::from(html))
.unwrap(),
)
}
Err(_) => {
Ok::<_, Infallible>(
Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "text/plain; charset=utf-8")
.body(Body::from(format!("404: {} not found", req.uri().path())))
.unwrap(),
)
}
}
}
}));
Router::new()
.merge(api_routes)
.fallback_service(static_service)
}
+298
View File
@@ -0,0 +1,298 @@
// Monitor routes — LDOH incident dashboard proxy with cookie auth
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{header, HeaderMap, StatusCode},
response::Response,
routing::{get, post, put},
Json, Router,
};
use serde_json::{json, Value};
use tracing::info;
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
const LDOH_COOKIE_SETTING_KEY: &str = "monitor_ldoh_cookie";
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/monitor/config",
get(get_monitor_config).put(update_monitor_config),
)
.route("/api/monitor/session", post(create_session))
.route("/monitor-proxy/ldoh", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/{*path}", get(ldoh_proxy_handler))
}
/// GET /api/monitor/config — returns whether LDOH cookie is configured
async fn get_monitor_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
let cookie: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let raw = cookie
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
let configured = !raw.is_empty();
let masked = if configured {
mask_cookie_value(&raw)
} else {
String::new()
};
Ok(Json(json!({
"ldohCookieConfigured": configured,
"ldohCookieMasked": masked,
})))
}
/// PUT /api/monitor/config — save LDOH cookie
async fn update_monitor_config(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let cookie = body
.get("ldohCookie")
.and_then(|v| v.as_str())
.unwrap_or("");
if cookie.is_empty() {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, '')")
.bind(LDOH_COOKIE_SETTING_KEY)
.execute(&*state.db)
.await?;
return Ok(Json(json!({
"success": true,
"message": "LDOH Cookie 已清空",
"ldohCookieConfigured": false,
})));
}
let normalized = normalize_ldoh_cookie(cookie);
if !normalized.starts_with("ld_auth_session=") || normalized.len() < 24 {
return Err(AppError::BadRequest("Cookie 格式无效".into()));
}
let escaped = serde_json::to_string(&normalized).unwrap_or_default();
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(LDOH_COOKIE_SETTING_KEY)
.bind(&escaped)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"message": "LDOH Cookie 已保存",
"ldohCookieConfigured": true,
"ldohCookieMasked": mask_cookie_value(&normalized),
})))
}
/// POST /api/monitor/session — returns cookie value for client to set
async fn create_session(State(state): State<AppState>) -> Json<Value> {
let token = &state.config.auth.auth_token;
Json(json!({
"success": true,
"_cookie": format!("{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=7200", MONITOR_AUTH_COOKIE, token),
}))
}
/// GET /monitor-proxy/ldoh/{*path} — reverse proxy to ldoh.105117.xyz
async fn ldoh_proxy_handler(
State(state): State<AppState>,
headers: HeaderMap,
Path(path): Path<String>,
) -> Result<Response, AppError> {
// Auth check via cookie
let cookies = parse_cookies(
headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
);
if cookies.get(MONITOR_AUTH_COOKIE).map(|s| s.as_str()) != Some(&state.config.auth.auth_token) {
return Err(AppError::Unauthorized(
"Missing or invalid monitor session".into(),
));
}
// Get stored LDOH cookie
let stored: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let ldoh_cookie = stored
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
if ldoh_cookie.is_empty() {
return Err(AppError::BadRequest("LDOH cookie not configured".into()));
}
// Build upstream URL
let clean_path = if path.is_empty() || path == "/" {
String::new()
} else {
format!("/{}", path)
};
let target_url = format!("{}{}", LDOH_BASE_URL, clean_path);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?;
let mut req = client
.get(&target_url)
.header("cookie", &ldoh_cookie)
.header(
"accept",
headers
.get("accept")
.and_then(|v| v.to_str().ok())
.unwrap_or("*/*"),
)
.header(
"accept-language",
headers
.get("accept-language")
.and_then(|v| v.to_str().ok())
.unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"),
)
.header(
"user-agent",
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("boosapiMonitorProxy/1.0"),
);
if let Some(referer) = headers.get("referer").and_then(|v| v.to_str().ok()) {
req = req.header("referer", referer.replace("/monitor-proxy/ldoh", ""));
}
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("LDOH 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 mut builder = axum::response::Response::builder().status(status);
if let Some(ctl) = resp
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok())
{
builder = builder.header("cache-control", ctl);
}
// Rewrite text content
if content_type.contains("text/html")
|| content_type.contains("javascript")
|| content_type.contains("text/css")
|| content_type.contains("application/json")
{
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
return Ok(builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?);
}
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok(builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(bytes))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?)
}
// ── Helpers ────────────────────────────────────────────────────────────
fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
for part in raw.split(';') {
let part = part.trim();
if let Some(idx) = part.find('=') {
let key = part[..idx].trim().to_string();
let val = part[idx + 1..].trim().to_string();
if !key.is_empty() {
map.insert(key, val);
}
}
}
map
}
fn mask_cookie_value(cookie_text: &str) -> String {
let raw = cookie_text.trim();
if raw.is_empty() {
return String::new();
}
let value = raw.split('=').nth(1).unwrap_or(raw);
if value.len() <= 10 {
format!("{}****", &value[..2.min(value.len())])
} else {
format!("{}****{}", &value[..6], &value[value.len() - 4..])
}
}
fn normalize_ldoh_cookie(raw: &str) -> String {
let trimmed = raw.trim().to_string();
if trimmed.contains("ld_auth_session=") {
if let Some(first) = trimmed.split(';').next() {
if first.trim().starts_with("ld_auth_session=") {
return first.trim().to_string();
}
}
}
format!("ld_auth_session={}", trimmed)
}
fn rewrite_proxy_text(text: &str) -> String {
text.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
.replace(
"https:\\/\\/ldoh.105117.xyz\\/",
"\\/monitor-proxy\\/ldoh\\/",
)
.replace("src=\"/", "src=\"/monitor-proxy/ldoh/")
.replace("src='/", "src='/monitor-proxy/ldoh/")
.replace("href=\"/", "href=\"/monitor-proxy/ldoh/")
.replace("href='/", "href='/monitor-proxy/ldoh/")
.replace("action=\"/", "action=\"/monitor-proxy/ldoh/")
.replace("action='/", "action='/monitor-proxy/ldoh/")
.replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/")
.replace("'/_next/", "'/monitor-proxy/ldoh/_next/")
.replace("\"\\\\/api/", "\"\\\\/monitor-proxy\\\\/ldoh\\\\/api/")
.replace("'/api/", "'/monitor-proxy/ldoh/api/")
.replace("\"/api/", "\"/monitor-proxy/ldoh/api/")
}
+312
View File
@@ -0,0 +1,312 @@
// OAuth routes — provider management, session handling, connection management
// Simplified implementation — complex OAuth flow operations return 501
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse},
routing::{get, post, put},
Json, Router,
};
use serde_json::{json, Value};
use tracing::info;
pub fn routes() -> Router<AppState> {
Router::new()
// Providers
.route("/api/oauth/providers", get(list_providers))
.route(
"/api/oauth/providers/{provider}/start",
post(start_provider_flow),
)
// Sessions
.route("/api/oauth/sessions/{state}", get(get_session))
.route(
"/api/oauth/sessions/{state}/manual-callback",
post(manual_callback),
)
// Connections
.route("/api/oauth/connections", get(list_connections))
.route(
"/api/oauth/connections/{id}/rebind",
post(rebind_connection),
)
.route(
"/api/oauth/connections/{id}/proxy",
post(update_connection_proxy),
)
.route("/api/oauth/connections/import", post(import_connections))
.route(
"/api/oauth/connections/{id}/refresh-quota",
post(refresh_connection_quota),
)
.route(
"/api/oauth/connections/refresh-quota-batch",
post(refresh_quota_batch),
)
// Route units
.route("/api/oauth/route-units", post(create_route_unit))
.route(
"/api/oauth/route-units/{id}",
put(update_route_unit).delete(delete_route_unit),
)
// Callback
.route("/api/oauth/callback", get(oauth_callback_page))
// Delete connection
.route("/api/oauth/connections/{id}", post(delete_connection))
}
// ── Auth helper ────────────────────────────────────────────────────────
fn require_admin(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let token = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token != state.config.auth.auth_token {
return Err(AppError::Unauthorized("Invalid admin token".into()));
}
Ok(())
}
// ── Providers ──────────────────────────────────────────────────────────
/// GET /api/oauth/providers — public
async fn list_providers() -> Json<Value> {
Json(json!({
"defaults": {
"proxyRequired": false,
"useSystemProxy": false,
},
"providers": [
{
"id": "openai",
"name": "OpenAI",
"type": "openai",
"enabled": true,
},
{
"id": "anthropic",
"name": "Anthropic",
"type": "anthropic",
"enabled": true,
},
{
"id": "google",
"name": "Google AI",
"type": "google",
"enabled": true,
},
],
}))
}
/// POST /api/oauth/providers/{provider}/start
async fn start_provider_flow(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_provider): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
info!("OAuth provider flow start: {}", _provider);
Err(AppError::BadRequest(
"OAuth provider flow not implemented in Rust backend".into(),
))
}
// ── Sessions ───────────────────────────────────────────────────────────
/// GET /api/oauth/sessions/{state}
async fn get_session(
State(_state): State<AppState>,
headers: HeaderMap,
Path(state): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
info!("OAuth session lookup: {}", state);
Err(AppError::NotFound(format!(
"OAuth session not found: {}",
state
)))
}
/// POST /api/oauth/sessions/{state}/manual-callback
async fn manual_callback(
State(_state): State<AppState>,
headers: HeaderMap,
Path(state): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
info!("OAuth manual callback: {}", state);
Err(AppError::BadRequest(
"OAuth manual callback not implemented in Rust backend".into(),
))
}
// ── Connections ────────────────────────────────────────────────────────
/// GET /api/oauth/connections
async fn list_connections(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
info!("Listing OAuth connections");
let accounts: Vec<Value> = sqlx::query_as::<_, (i64, i64, Option<String>, String, Option<String>, Option<String>, Option<String>, String)>(
"SELECT id, site_id, username, access_token, oauth_provider, oauth_account_key, oauth_project_id, status \
FROM accounts WHERE oauth_provider IS NOT NULL ORDER BY id"
)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|(id, site_id, username, _access_token, oauth_provider, oauth_account_key, oauth_project_id, status)| {
json!({
"id": id,
"siteId": site_id,
"username": username,
"oauthProvider": oauth_provider,
"oauthAccountKey": oauth_account_key,
"oauthProjectId": oauth_project_id,
"status": status,
})
})
.collect();
Ok(Json(json!({ "data": accounts, "total": accounts.len() })))
}
/// POST /api/oauth/connections/{id}/rebind
async fn rebind_connection(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth rebind not implemented in Rust backend".into(),
))
}
/// POST /api/oauth/connections/{id}/proxy
async fn update_connection_proxy(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth proxy update not implemented in Rust backend".into(),
))
}
/// POST /api/oauth/connections/import
async fn import_connections(
State(_state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth import not implemented in Rust backend".into(),
))
}
/// POST /api/oauth/connections/{id}/refresh-quota
async fn refresh_connection_quota(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth quota refresh not implemented in Rust backend".into(),
))
}
/// POST /api/oauth/connections/refresh-quota-batch
async fn refresh_quota_batch(
State(_state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth batch quota refresh not implemented in Rust backend".into(),
))
}
// ── Route Units ────────────────────────────────────────────────────────
/// POST /api/oauth/route-units
async fn create_route_unit(
State(_state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth route unit creation not implemented in Rust backend".into(),
))
}
/// PUT /api/oauth/route-units/{id}
async fn update_route_unit(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth route unit update not implemented in Rust backend".into(),
))
}
/// DELETE /api/oauth/route-units/{id}
async fn delete_route_unit(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth route unit deletion not implemented in Rust backend".into(),
))
}
// ── Callback ───────────────────────────────────────────────────────────
/// GET /api/oauth/callback — public, returns HTML for popup window
async fn oauth_callback_page(headers: HeaderMap) -> impl IntoResponse {
info!("OAuth callback received");
let html = r#"<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8" /><title>OAuth Callback</title></head>
<body>
<script>window.close();</script>
OAuth callback received. You can close this window.
</body>
</html>"#;
(
StatusCode::OK,
[("Content-Type", "text/html; charset=utf-8")],
Html(html),
)
}
// ── Delete ─────────────────────────────────────────────────────────────
/// POST /api/oauth/connections/{id} — delete connection
async fn delete_connection(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth connection deletion not implemented in Rust backend".into(),
))
}
+562
View File
@@ -0,0 +1,562 @@
// Organizations, members, and invitations routes
use crate::{
auth::jwt::validate_jwt,
db::models::organization::{Invitation, Organization, OrganizationMember},
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
routing::{delete, get, patch, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
// ── Auth helper ────────────────────────────────────────────────────────
fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, String)> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let user_id: i64 = claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
Ok((user_id, claims.role))
}
/// Check if user is org admin (owner or admin role).
async fn require_org_admin(db: &sqlx::SqlitePool, org_id: i64, user_id: i64) -> AppResult<()> {
let member: Option<OrganizationMember> =
sqlx::query_as("SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?")
.bind(org_id)
.bind(user_id)
.fetch_optional(db)
.await?;
match member {
Some(m) if m.role.as_deref() == Some("owner") || m.role.as_deref() == Some("admin") => {
Ok(())
}
_ => Err(AppError::Forbidden("Admin access required".into())),
}
}
/// Check if user is a member of an org.
async fn is_org_member(db: &sqlx::SqlitePool, org_id: i64, user_id: i64) -> Result<bool, AppError> {
let member: Option<OrganizationMember> =
sqlx::query_as("SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?")
.bind(org_id)
.bind(user_id)
.fetch_optional(db)
.await?;
Ok(member.is_some())
}
// ── Routes ─────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct ListOrgsQuery {
limit: Option<i64>,
}
pub fn routes() -> Router<AppState> {
Router::new()
// Organizations CRUD
.route(
"/api/organizations",
get(list_organizations).post(create_organization),
)
.route("/api/organizations/plans", get(list_plans))
.route(
"/api/organizations/{org_id}",
get(get_organization).patch(update_organization),
)
// Members
.route("/api/organizations/{org_id}/members", get(list_members))
.route(
"/api/organizations/{org_id}/members/{user_id}",
patch(update_member_role).delete(remove_member),
)
// Invitations
.route(
"/api/organizations/{org_id}/invitations",
get(list_invitations).post(create_invitation),
)
.route(
"/api/organizations/{org_id}/invitations/{inv_id}",
delete(cancel_invitation),
)
// Public invitation routes (no org prefix)
.route("/api/invitations/{token}", get(get_invitation))
.route("/api/invitations/{token}/accept", post(accept_invitation))
}
// ── Organization Handlers ──────────────────────────────────────────────
/// GET /api/organizations
async fn list_organizations(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListOrgsQuery>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
let limit = q.limit.unwrap_or(50).clamp(1, 500);
let orgs: Vec<Organization> = sqlx::query_as(
r#"SELECT o.* FROM organizations o
INNER JOIN organization_members om ON om.org_id = o.id
WHERE om.user_id = ?
ORDER BY o.created_at DESC
LIMIT ?"#,
)
.bind(user_id)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "organizations": orgs })))
}
/// POST /api/organizations
async fn create_organization(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> AppResult<(StatusCode, Json<Value>)> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
let name = body
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("name is required".into()))?;
let slug = body
.get("slug")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("slug is required".into()))?;
let result = sqlx::query("INSERT INTO organizations (name, slug) VALUES (?, ?)")
.bind(name)
.bind(slug)
.execute(&*state.db)
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("UNIQUE") {
AppError::BadRequest("Organization slug already exists".into())
} else {
AppError::Internal(anyhow::anyhow!("Failed to create organization: {e}"))
}
})?;
let org_id = result.last_insert_rowid();
// Add creator as owner
sqlx::query("INSERT INTO organization_members (org_id, user_id, role) VALUES (?, ?, 'owner')")
.bind(org_id)
.bind(user_id)
.execute(&*state.db)
.await?;
let org: Organization = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
.bind(org_id)
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "organization": org }))))
}
/// GET /api/organizations/plans
async fn list_plans() -> AppResult<Json<Value>> {
// Static plan definitions matching Node.js listAllPlans()
let plans = json!([
{
"id": "free",
"name": "免费",
"description": "基础功能",
"limits": { "members": 3, "sites": 2 },
"price": 0,
},
{
"id": "pro",
"name": "专业版",
"description": "进阶功能",
"limits": { "members": 20, "sites": 20 },
"price": 9900,
},
{
"id": "enterprise",
"name": "企业版",
"description": "全功能",
"limits": { "members": 100, "sites": 100 },
"price": 49900,
},
]);
Ok(Json(json!({ "plans": plans })))
}
/// GET /api/organizations/:org_id
async fn get_organization(
State(state): State<AppState>,
headers: HeaderMap,
Path(org_id): Path<i64>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
if !is_org_member(&state.db, org_id, user_id).await? {
return Err(AppError::Forbidden(
"You are not a member of this organization".into(),
));
}
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
.bind(org_id)
.fetch_optional(&*state.db)
.await?;
match org {
Some(o) => Ok(Json(json!({ "organization": o }))),
None => Err(AppError::NotFound("Organization not found".into())),
}
}
/// PATCH /api/organizations/:org_id
async fn update_organization(
State(state): State<AppState>,
headers: HeaderMap,
Path(org_id): Path<i64>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let name = body.get("name").and_then(|v| v.as_str());
let slug = body.get("slug").and_then(|v| v.as_str());
if name.is_none() && slug.is_none() {
return Err(AppError::BadRequest("No fields to update".into()));
}
if let Some(s) = slug {
sqlx::query("UPDATE organizations SET slug = ?, updated_at = datetime('now') WHERE id = ?")
.bind(s)
.bind(org_id)
.execute(&*state.db)
.await?;
}
if let Some(n) = name {
sqlx::query("UPDATE organizations SET name = ?, updated_at = datetime('now') WHERE id = ?")
.bind(n)
.bind(org_id)
.execute(&*state.db)
.await?;
}
let org: Organization = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
.bind(org_id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "organization": org })))
}
// ── Member Handlers ────────────────────────────────────────────────────
/// GET /api/organizations/:org_id/members
async fn list_members(
State(state): State<AppState>,
headers: HeaderMap,
Path(org_id): Path<i64>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
if !is_org_member(&state.db, org_id, user_id).await? {
return Err(AppError::Forbidden(
"You are not a member of this organization".into(),
));
}
let members: Vec<Value> = sqlx::query_as::<_, OrganizationMember>(
"SELECT * FROM organization_members WHERE org_id = ? ORDER BY joined_at ASC",
)
.bind(org_id)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|m| {
json!({
"id": m.id,
"orgId": m.org_id,
"userId": m.user_id,
"role": m.role,
"status": m.status,
"joinedAt": m.joined_at,
})
})
.collect();
Ok(Json(json!({ "members": members })))
}
/// PATCH /api/organizations/:org_id/members/:user_id
async fn update_member_role(
State(state): State<AppState>,
headers: HeaderMap,
Path((org_id, target_user_id)): Path<(i64, i64)>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let role = body
.get("role")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("role is required".into()))?;
if !["owner", "admin", "member"].contains(&role) {
return Err(AppError::BadRequest(
"Role must be one of: owner, admin, member".into(),
));
}
sqlx::query("UPDATE organization_members SET role = ? WHERE org_id = ? AND user_id = ?")
.bind(role)
.bind(org_id)
.bind(target_user_id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// DELETE /api/organizations/:org_id/members/:user_id
async fn remove_member(
State(state): State<AppState>,
headers: HeaderMap,
Path((org_id, target_user_id)): Path<(i64, i64)>,
) -> AppResult<StatusCode> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
sqlx::query("DELETE FROM organization_members WHERE org_id = ? AND user_id = ?")
.bind(org_id)
.bind(target_user_id)
.execute(&*state.db)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── Invitation Handlers ────────────────────────────────────────────────
/// GET /api/organizations/:org_id/invitations
async fn list_invitations(
State(state): State<AppState>,
headers: HeaderMap,
Path(org_id): Path<i64>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let invitations: Vec<Invitation> =
sqlx::query_as("SELECT * FROM invitations WHERE org_id = ? ORDER BY created_at DESC")
.bind(org_id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "invitations": invitations })))
}
/// POST /api/organizations/:org_id/invitations
async fn create_invitation(
State(state): State<AppState>,
headers: HeaderMap,
Path(org_id): Path<i64>,
Json(body): Json<serde_json::Value>,
) -> AppResult<(StatusCode, Json<Value>)> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let email = body
.get("email")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("Email is required".into()))?;
let role = body
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("member");
if !["admin", "member"].contains(&role) {
return Err(AppError::BadRequest(
"Role must be one of: admin, member".into(),
));
}
// Generate token
let token = format!("inv-{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
let expires_at = chrono::Utc::now()
.checked_add_signed(chrono::Duration::days(7))
.unwrap()
.format("%Y-%m-%d %H:%M:%S")
.to_string();
let result = sqlx::query(
"INSERT INTO invitations (org_id, email, token, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(org_id)
.bind(email)
.bind(&token)
.bind(role)
.bind(user_id)
.bind(&expires_at)
.execute(&*state.db)
.await?;
let inv: Invitation = sqlx::query_as("SELECT * FROM invitations WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "invitation": inv }))))
}
/// DELETE /api/organizations/:org_id/invitations/:inv_id
async fn cancel_invitation(
State(state): State<AppState>,
headers: HeaderMap,
Path((org_id, inv_id)): Path<(i64, i64)>,
) -> AppResult<StatusCode> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
sqlx::query("UPDATE invitations SET status = 'cancelled' WHERE id = ? AND org_id = ?")
.bind(inv_id)
.bind(org_id)
.execute(&*state.db)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── Public Invitation Handlers ─────────────────────────────────────────
/// GET /api/invitations/:token — public, no auth required
async fn get_invitation(
State(state): State<AppState>,
Path(token): Path<String>,
) -> AppResult<Json<Value>> {
let invitation: Option<Invitation> =
sqlx::query_as("SELECT * FROM invitations WHERE token = ? AND status = 'pending'")
.bind(&token)
.fetch_optional(&*state.db)
.await?;
match invitation {
Some(i) => {
// Check expiry
let expired = i.expires_at.as_ref().map_or(false, |e| {
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
.unwrap_or(false)
});
if expired {
return Err(AppError::NotFound("Invitation not found or expired".into()));
}
// Get org name
let org_name: Option<(String,)> =
sqlx::query_as("SELECT name FROM organizations WHERE id = ?")
.bind(i.org_id)
.fetch_optional(&*state.db)
.await?;
Ok(Json(json!({
"invitation": {
"id": i.id,
"orgId": i.org_id,
"orgName": org_name.map(|n| n.0).unwrap_or_default(),
"email": i.email,
"token": i.token,
"role": i.role,
"invitedByUsername": "",
"status": i.status,
"expiresAt": i.expires_at,
"createdAt": i.created_at,
}
})))
}
None => Err(AppError::NotFound("Invitation not found or expired".into())),
}
}
/// POST /api/invitations/:token/accept — JWT auth required
async fn accept_invitation(
State(state): State<AppState>,
headers: HeaderMap,
Path(token): Path<String>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
let invitation: Option<Invitation> =
sqlx::query_as("SELECT * FROM invitations WHERE token = ? AND status = 'pending'")
.bind(&token)
.fetch_optional(&*state.db)
.await?;
let inv = match invitation {
Some(i) => i,
None => {
return Err(AppError::BadRequest(
"Invitation not found or expired".into(),
))
}
};
// Check expiry
let expired = inv.expires_at.as_ref().map_or(true, |e| {
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
.unwrap_or(true)
});
if expired {
return Err(AppError::BadRequest(
"Invitation not found or expired".into(),
));
}
// Mark invitation as accepted
sqlx::query("UPDATE invitations SET status = 'accepted' WHERE id = ?")
.bind(inv.id)
.execute(&*state.db)
.await?;
// Add user as member
sqlx::query(
"INSERT OR IGNORE INTO organization_members (org_id, user_id, role) VALUES (?, ?, ?)",
)
.bind(inv.org_id)
.bind(user_id)
.bind(&inv.role)
.execute(&*state.db)
.await?;
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
.bind(inv.org_id)
.fetch_optional(&*state.db)
.await?;
Ok(Json(json!({ "organization": org })))
}
+679
View File
@@ -0,0 +1,679 @@
// Proxy routes — OpenAI-compatible /v1/ endpoints
//
// These routes handle:
// POST /v1/chat/completions — streaming and non-streaming
// POST /v1/images/generations — image generation
// GET /v1/models — list available models
use crate::{
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
error::{AppError, AppResult},
proxy::conductor::{
AttemptResult, ExecuteInput, ExecuteResult, ProxyConductor, SelectedChannel,
},
proxy::executors::{self, UpstreamRoute},
proxy::token_router::TokenRouter,
AppState,
};
use axum::{
extract::State,
http::HeaderMap,
response::{IntoResponse, Response, Sse},
routing::{get, post},
Json, Router,
};
use futures::{stream, FutureExt, StreamExt};
use serde_json::{json, Value};
use std::{convert::Infallible, sync::Arc};
use tracing::info;
// ── Auth types ────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct ProxyAuth {
pub token: String,
pub key_id: Option<i64>,
pub source: DownstreamSource,
}
#[derive(Clone, PartialEq)]
pub enum DownstreamSource {
Managed { key_id: i64 },
Global,
}
// ── Route definitions ─────────────────────────────────────────────────
/// Build the /v1/ proxy router
pub fn routes() -> Router<AppState> {
Router::new()
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/completions", post(completions_handler))
.route("/v1/embeddings", post(embeddings_handler))
.route("/v1/audio/transcriptions", post(audio_transcriptions))
.route("/v1/audio/translations", post(audio_translations))
.route("/v1/audio/speech", post(audio_speech))
.route("/v1/images/generations", post(images_generations))
.route("/v1/videos", post(videos_handler))
.route("/v1/models", get(list_models))
}
// ── Handlers ───────────────────────────────────────────────────────────
/// POST /v1/chat/completions
async fn chat_completions(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Response, AppError> {
info!("POST /v1/chat/completions");
check_auth(&state, &headers).await?;
let model = body
.get("model")
.and_then(|m| m.as_str())
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let stream = body
.get("stream")
.and_then(|s| s.as_bool())
.unwrap_or(false);
if stream {
let chunks = execute_chat_completions_stream_with_conductor(state, model, body).await?;
let sse =
Sse::new(stream::iter(chunks).map(|chunk| {
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
}));
Ok(sse.into_response())
} else {
let result = execute_chat_completions_with_conductor(state, model, body).await?;
Ok(Json(result).into_response())
}
}
/// POST /v1/completions
async fn completions_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
info!("POST /v1/completions");
check_auth(&state, &headers).await?;
let model = body
.get("model")
.and_then(|m| m.as_str())
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let route = resolve_route(&state, &model).await?;
let result = executors::execute_proxy_request(state, route, "/completions", body).await?;
Ok(Json(result))
}
/// POST /v1/embeddings
async fn embeddings_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
info!("POST /v1/embeddings");
check_auth(&state, &headers).await?;
let model = body
.get("model")
.and_then(|m| m.as_str())
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let route = resolve_route(&state, &model).await?;
let result = executors::execute_proxy_request(state, route, "/embeddings", body).await?;
Ok(Json(result))
}
/// POST /v1/audio/speech
async fn audio_speech(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
info!("POST /v1/audio/speech");
check_auth(&state, &headers).await?;
let model = body
.get("model")
.and_then(|m| m.as_str())
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let route = resolve_route(&state, &model).await?;
let result = executors::execute_proxy_request(state, route, "/audio/speech", body).await?;
Ok(Json(result))
}
/// POST /v1/audio/transcriptions — proxy to upstream
async fn audio_transcriptions(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
info!("POST /v1/audio/transcriptions");
check_auth(&state, &headers).await?;
let model = body
.get("model")
.and_then(|m| m.as_str())
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let route = resolve_route(&state, &model).await?;
let result =
executors::execute_proxy_request(state, route, "/audio/transcriptions", body).await?;
Ok(Json(result))
}
/// POST /v1/audio/translations — proxy to upstream
async fn audio_translations(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
info!("POST /v1/audio/translations");
check_auth(&state, &headers).await?;
let model = body
.get("model")
.and_then(|m| m.as_str())
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let route = resolve_route(&state, &model).await?;
let result =
executors::execute_proxy_request(state, route, "/audio/translations", body).await?;
Ok(Json(result))
}
/// POST /v1/videos — list/create video generation tasks
async fn videos_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
info!("POST /v1/videos");
check_auth(&state, &headers).await?;
let model = body
.get("model")
.and_then(|m| m.as_str())
.unwrap_or("default");
let route = resolve_route(&state, model).await?;
let result = executors::execute_proxy_request(state, route, "/videos", body).await?;
Ok(Json(result))
}
/// POST /v1/images/generations
async fn images_generations(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
info!("POST /v1/images/generations");
check_auth(&state, &headers).await?;
let model = body
.get("model")
.and_then(|m| m.as_str())
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let route = resolve_route(&state, &model).await?;
let result = executors::execute_image_generation(state, route, body).await?;
Ok(Json(result))
}
/// GET /v1/models
async fn list_models(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
info!("GET /v1/models");
check_auth(&state, &headers).await?;
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT tr.model_pattern, COALESCE(tr.model_mapping, tr.model_pattern) FROM token_routes tr WHERE tr.enabled = 1 ORDER BY tr.id",
)
.fetch_all(&*state.db)
.await?;
let models: Vec<Value> = rows
.into_iter()
.map(|(name, model)| {
json!({
"id": model,
"object": "model",
"created": 0,
"owned_by": name,
})
})
.collect();
Ok(Json(json!({
"object": "list",
"data": models,
})))
}
// ── Auth helper ───────────────────────────────────────────────────────
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()))?;
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(()),
}
}
// ── Route Resolution ──────────────────────────────────────────────────
/// Resolve upstream endpoint + API key for a given model.
async fn resolve_route(state: &AppState, model: &str) -> AppResult<UpstreamRoute> {
let selected = TokenRouter::new(state.db.clone())
.resolve_selected(model)
.await?;
Ok(selected_channel_to_route(selected))
}
fn selected_channel_to_route(selected: SelectedChannel) -> UpstreamRoute {
UpstreamRoute {
base_url: selected.base_url,
api_key: selected.api_key,
model: selected.actual_model,
}
}
fn body_with_selected_model(mut body: Value, selected: &SelectedChannel) -> Value {
if let Some(object) = body.as_object_mut() {
object.insert(
"model".to_string(),
Value::String(selected.actual_model.clone()),
);
}
body
}
fn build_conductor(state: &AppState) -> ProxyConductor {
let db = state.db.clone();
let select_db = db.clone();
let select_next_db = db.clone();
let success_db = db.clone();
let failure_db = db;
ProxyConductor {
select_channel: Arc::new(move |model, _policy| {
let router = TokenRouter::new(select_db.clone());
async move { router.resolve_selected(&model).await.ok() }.boxed()
}),
select_next_channel: Arc::new(move |model, excluded, _policy| {
let router = TokenRouter::new(select_next_db.clone());
async move { router.resolve_next_selected(&model, &excluded).await.ok() }.boxed()
}),
record_success: Arc::new(move |channel_id, metrics| {
let router = TokenRouter::new(success_db.clone());
async move {
if let Err(err) = router.record_success(channel_id, metrics).await {
tracing::warn!(%err, channel_id, "failed to record proxy success");
}
}
.boxed()
}),
record_failure: Arc::new(move |channel_id, info| {
let router = TokenRouter::new(failure_db.clone());
async move {
if let Err(err) = router.record_failure(channel_id, info).await {
tracing::warn!(%err, channel_id, "failed to record proxy failure");
}
}
.boxed()
}),
refresh_auth: None,
}
}
async fn execute_chat_completions_with_conductor(
state: AppState,
model: String,
body: Value,
) -> AppResult<Value> {
let conductor = build_conductor(&state);
let result = conductor
.execute(ExecuteInput {
requested_model: model,
downstream_policy: None,
attempt_fn: Box::new(move |ctx| {
let state = state.clone();
let body = body_with_selected_model(body.clone(), &ctx.selected);
async move {
match executors::execute_chat_completions(
state,
selected_channel_to_route(ctx.selected),
body,
)
.await
{
Ok(response) => AttemptResult::Success {
response,
latency_ms: None,
cost: None,
},
Err(err) => AttemptResult::Failure {
action: crate::proxy::conductor::AttemptFailureAction::Failover,
status: None,
raw_error_text: Some(err.to_string()),
},
}
}
.boxed()
}),
on_terminal_failure: None,
})
.await;
conductor_result_to_app_result(result)
}
async fn execute_chat_completions_stream_with_conductor(
state: AppState,
model: String,
body: Value,
) -> AppResult<Vec<String>> {
let conductor = build_conductor(&state);
let result = conductor
.execute(ExecuteInput {
requested_model: model,
downstream_policy: None,
attempt_fn: Box::new(move |ctx| {
let state = state.clone();
let body = body_with_selected_model(body.clone(), &ctx.selected);
async move {
match executors::execute_chat_completions_stream(
state,
selected_channel_to_route(ctx.selected),
body,
)
.await
{
Ok(chunks) => AttemptResult::Success {
response: Value::Array(chunks.into_iter().map(Value::String).collect()),
latency_ms: None,
cost: None,
},
Err(err) => AttemptResult::Failure {
action: crate::proxy::conductor::AttemptFailureAction::Failover,
status: None,
raw_error_text: Some(err.to_string()),
},
}
}
.boxed()
}),
on_terminal_failure: None,
})
.await;
match conductor_result_to_app_result(result)? {
Value::Array(chunks) => chunks
.into_iter()
.map(|chunk| {
chunk.as_str().map(str::to_string).ok_or_else(|| {
AppError::UpstreamError("Invalid streaming response from conductor".into())
})
})
.collect(),
_ => Err(AppError::UpstreamError(
"Invalid streaming response from conductor".into(),
)),
}
}
fn conductor_result_to_app_result(result: ExecuteResult) -> AppResult<Value> {
match result {
ExecuteResult::Success { response, .. } => Ok(response),
ExecuteResult::Failure {
reason,
status,
raw_error_text,
attempts,
..
} => Err(AppError::UpstreamError(format!(
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
status,
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use axum::http::HeaderValue;
use serde_json::json;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state(upstream_base_url: String) -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query(
"CREATE TABLE downstream_api_keys (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled INTEGER DEFAULT 1,
expires_at TEXT,
max_cost REAL,
used_cost REAL DEFAULT 0,
max_requests INTEGER,
used_requests INTEGER DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE sites (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'openai',
status TEXT NOT NULL DEFAULT 'active',
api_key TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE token_routes (
id INTEGER PRIMARY KEY,
model_pattern TEXT NOT NULL,
model_mapping TEXT,
route_mode TEXT,
enabled INTEGER DEFAULT 1
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
access_token TEXT NOT NULL,
api_token TEXT,
status TEXT NOT NULL DEFAULT 'active'
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE account_tokens (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
enabled INTEGER DEFAULT 1
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE route_channels (
id INTEGER PRIMARY KEY,
route_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
token_id INTEGER,
source_model TEXT,
priority INTEGER DEFAULT 0,
weight INTEGER DEFAULT 10,
enabled INTEGER DEFAULT 1,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_fail_at TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO downstream_api_keys (id, name, key, enabled) VALUES (1, 'test', 'sk-test', 1)")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO sites (id, name, url, api_key) VALUES (1, 'OpenAI', ?, 'site-key')",
)
.bind(upstream_base_url)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO accounts (id, site_id, access_token, api_token) VALUES (1, 1, 'access-key', NULL)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO token_routes (id, model_pattern, model_mapping, route_mode, enabled) VALUES (1, 'gpt-test', 'upstream-test', 'exact', 1)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO route_channels (id, route_id, account_id) VALUES (1, 1, 1)")
.execute(&pool)
.await
.unwrap();
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
},
},
pool,
)
}
#[tokio::test]
async fn chat_route_uses_token_router_and_conductor() {
let state = test_state("http://127.0.0.1:1".to_string()).await;
let db = state.db.clone();
let mut headers = HeaderMap::new();
headers.insert("Authorization", HeaderValue::from_static("Bearer sk-test"));
let result = chat_completions(
State(state),
headers,
Json(json!({
"model": "gpt-test",
"messages": [{"role": "user", "content": "hello"}]
})),
)
.await;
assert!(matches!(result, Err(AppError::UpstreamError(_))));
let fail_count: i64 =
sqlx::query_scalar("SELECT fail_count FROM route_channels WHERE id = 1")
.fetch_one(&*db)
.await
.unwrap();
assert!(fail_count > 0);
}
}
+214
View File
@@ -0,0 +1,214 @@
// Search routes — cross-entity search across sites, accounts, tokens, etc.
use crate::{error::AppResult, AppState};
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct SearchBody {
query: String,
limit: Option<i64>,
}
pub fn routes() -> Router<AppState> {
Router::new().route("/api/search", post(search_handler))
}
/// POST /api/search — search across all entities
async fn search_handler(
State(state): State<AppState>,
Json(body): Json<SearchBody>,
) -> AppResult<Json<Value>> {
let query = body.query.trim().to_string();
let limit = body.limit.unwrap_or(20).max(1).min(100);
if query.is_empty() {
return Ok(Json(json!({
"accounts": [], "accountTokens": [], "sites": [],
"checkinLogs": [], "proxyLogs": [], "models": [],
})));
}
let q = format!("%{}%", query);
let per_category = (limit / 6).max(1).min(10);
// 1. Search sites
let sites: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String)>(
"SELECT id, name, url, platform FROM sites WHERE name LIKE ? OR url LIKE ? OR platform LIKE ? LIMIT ?"
)
.bind(&q).bind(&q).bind(&q).bind(per_category)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|(id, name, url, platform)| json!({"id": id, "name": name, "url": url, "platform": platform}))
.collect::<Vec<_>>();
// Deduplicate by id
let mut seen_sites = HashSet::new();
let unique_sites: Vec<Value> = sites
.into_iter()
.filter(|s| {
let id = s["id"].as_i64().unwrap_or(0);
seen_sites.insert(id)
})
.collect();
// 2. Search accounts (with site join)
let accounts: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String, i64, String, String, Option<String>)>(
r#"SELECT a.id, a.username, a.status, a.extra_config, s.id, s.name, s.platform, a.access_token
FROM accounts a
INNER JOIN sites s ON s.id = a.site_id
WHERE a.username LIKE ? OR s.name LIKE ? OR s.platform LIKE ?
LIMIT ?"#
)
.bind(&q).bind(&q).bind(&q).bind(per_category)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|(id, username, status, extra_config, site_id, site_name, platform, access_token)| {
let segment = if access_token.as_deref().unwrap_or("").trim().is_empty() { "apikey" } else { "session" };
json!({
"id": id, "username": username, "status": status,
"segment": segment,
"site": {"id": site_id, "name": site_name, "platform": platform},
})
})
.collect();
// 3. Search account tokens (with account + site join)
let account_tokens: Vec<Value> = sqlx::query_as::<_, (i64, String, i64, String, i64, String, String)>(
r#"SELECT t.id, t.name, a.id, a.username, s.id, s.name, s.platform
FROM account_tokens t
INNER JOIN accounts a ON a.id = t.account_id
INNER JOIN sites s ON s.id = a.site_id
WHERE t.name LIKE ? OR t.token_group LIKE ? OR a.username LIKE ? OR s.name LIKE ? OR s.platform LIKE ?
ORDER BY t.updated_at DESC
LIMIT ?"#
)
.bind(&q).bind(&q).bind(&q).bind(&q).bind(&q).bind(per_category)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|(id, name, acct_id, username, site_id, site_name, platform)| {
json!({
"id": id, "name": name,
"account": {"id": acct_id, "username": username},
"site": {"id": site_id, "name": site_name, "platform": platform},
})
})
.collect();
// 4. Search checkin logs
let checkin_logs: Vec<Value> = sqlx::query_as::<_, (i64, String, i64, String)>(
r#"SELECT c.id, c.message, a.id, a.username
FROM checkin_logs c
INNER JOIN accounts a ON a.id = c.account_id
WHERE c.message LIKE ?
ORDER BY c.created_at DESC
LIMIT ?"#
)
.bind(&q).bind(per_category)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|(id, message, acct_id, username)| {
json!({"id": id, "message": message, "account": {"id": acct_id, "username": username}})
})
.collect();
// 5. Search proxy logs (by model)
let proxy_logs: Vec<Value> = sqlx::query_as::<_, (i64, String, String, i64, String, i64, String)>(
r#"SELECT p.id, p.model_requested, p.status, p.http_status, p.created_at,
COALESCE(a.id, 0), COALESCE(a.username, '')
FROM proxy_logs p
LEFT JOIN accounts a ON a.id = p.account_id
WHERE p.model_requested LIKE ?
ORDER BY p.created_at DESC
LIMIT ?"#
)
.bind(&q).bind(per_category)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|(id, model, status, http_status, created_at, acct_id, username)| {
json!({"id": id, "modelRequested": model, "status": status, "httpStatus": http_status, "createdAt": created_at})
})
.collect();
// 6. Search models (from model_availability + token_model_availability)
let token_model_rows: Vec<(String, i64, i64, i64)> = sqlx::query_as(
r#"SELECT DISTINCT tma.model_name, t.id, a.id, s.id
FROM token_model_availability tma
INNER JOIN account_tokens t ON t.id = tma.token_id AND t.enabled = 1
INNER JOIN accounts a ON a.id = t.account_id AND a.status = 'active'
INNER JOIN sites s ON s.id = a.site_id
WHERE tma.model_name LIKE ? AND tma.available = 1 AND t.value_status = 'ready'
LIMIT ?"#,
)
.bind(&q)
.bind(per_category * 10)
.fetch_all(&*state.db)
.await?;
let direct_model_rows: Vec<(String, i64, i64)> = sqlx::query_as(
r#"SELECT DISTINCT ma.model_name, a.id, s.id
FROM model_availability ma
INNER JOIN accounts a ON a.id = ma.account_id AND a.status = 'active'
INNER JOIN sites s ON s.id = a.site_id AND s.status = 'active'
WHERE ma.model_name LIKE ? AND ma.available = 1
LIMIT ?"#,
)
.bind(&q)
.bind(per_category * 10)
.fetch_all(&*state.db)
.await?;
// Aggregate models
let mut model_agg: HashMap<String, (HashSet<i64>, HashSet<i64>, HashSet<i64>)> = HashMap::new();
for (model_name, _token_id, account_id, site_id) in token_model_rows {
let entry = model_agg.entry(model_name).or_default();
entry.0.insert(account_id);
entry.1.insert(site_id);
}
for (model_name, account_id, site_id) in direct_model_rows {
let entry = model_agg.entry(model_name).or_default();
entry.0.insert(account_id);
entry.1.insert(site_id);
}
let mut models: Vec<Value> = model_agg
.into_iter()
.map(|(name, (accounts_set, sites_set, _tokens_set))| {
json!({
"name": name,
"accountCount": accounts_set.len(),
"tokenCount": 0,
"siteCount": sites_set.len(),
})
})
.collect();
models.sort_by(|a, b| {
let bc = b["accountCount"].as_i64().unwrap_or(0);
let ac = a["accountCount"].as_i64().unwrap_or(0);
bc.cmp(&ac).then_with(|| {
a["name"]
.as_str()
.unwrap_or("")
.cmp(&b["name"].as_str().unwrap_or(""))
})
});
models.truncate(per_category as usize);
Ok(Json(json!({
"accounts": accounts,
"accountTokens": account_tokens,
"sites": unique_sites,
"checkinLogs": checkin_logs,
"proxyLogs": proxy_logs,
"models": models,
})))
}
+180
View File
@@ -0,0 +1,180 @@
// Site Announcements routes — upstream provider notifications per site
use crate::{
db::models::site_announcement::SiteAnnouncement,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
routing::{delete, get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct ListAnnouncementsQuery {
limit: Option<i64>,
offset: Option<i64>,
site_id: Option<i64>,
platform: Option<String>,
read: Option<String>, // "true" | "false"
status: Option<String>, // "active" | "expired" | "dismissed"
}
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/site-announcements",
get(list_announcements).delete(delete_all),
)
.route("/api/site-announcements/{id}/read", post(mark_read))
.route("/api/site-announcements/read-all", post(mark_all_read))
}
/// GET /api/site-announcements — list announcements with optional filters
async fn list_announcements(
State(state): State<AppState>,
Query(q): Query<ListAnnouncementsQuery>,
) -> AppResult<Json<Value>> {
info!("Listing site announcements");
let limit = q.limit.unwrap_or(50).clamp(1, 500);
let offset = q.offset.unwrap_or(0).max(0);
let mut sql = "SELECT * FROM site_announcements WHERE 1=1".to_string();
let mut params: Vec<String> = vec![];
let mut bind_idx = 0;
if let Some(site_id) = q.site_id {
bind_idx += 1;
sql.push_str(&format!(" AND site_id = ?{}", bind_idx));
params.push(site_id.to_string());
}
if let Some(ref platform) = q.platform {
if !platform.is_empty() {
bind_idx += 1;
sql.push_str(&format!(" AND platform = ?{}", bind_idx));
params.push(platform.clone());
}
}
if let Some(ref r) = q.read {
if r == "true" {
sql.push_str(" AND read_at IS NOT NULL");
} else if r == "false" {
sql.push_str(" AND read_at IS NULL");
}
}
// status filter: active = NOT dismissed AND (ends_at IS NULL OR ends_at > now)
// We fetch all and apply status filter in Rust for simplicity
// (the "expired" vs "active" logic references ends_at which is a datetime comparison)
sql.push_str(" ORDER BY first_seen_at DESC");
let mut query = sqlx::query_as::<_, SiteAnnouncement>(&sql);
for p in &params {
query = query.bind(p);
}
let announcements: Vec<SiteAnnouncement> = query.fetch_all(&*state.db).await?;
// Apply status filter in-memory (matches Node.js behaviour)
let filtered = if let Some(ref status) = q.status {
let now = chrono::Utc::now().timestamp_millis() as i64;
let status = status.trim().to_lowercase();
announcements
.into_iter()
.filter(|a| {
if status == "dismissed" {
a.dismissed_at.is_some()
} else if status == "expired" {
a.dismissed_at.is_none()
&& a.ends_at
.as_ref()
.map_or(false, |e| parse_dt_millis(e) < now)
} else if status == "active" {
a.dismissed_at.is_none()
&& a.ends_at
.as_ref()
.map_or(true, |e| parse_dt_millis(e) >= now)
} else {
true
}
})
.collect()
} else {
announcements
};
// Apply pagination in-memory
let paginated: Vec<Value> = filtered
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.map(|a| {
json!({
"id": a.id,
"siteId": a.site_id,
"platform": a.platform,
"sourceKey": a.source_key,
"title": a.title,
"content": a.content,
"level": a.level,
"sourceUrl": a.source_url,
"startsAt": a.starts_at,
"endsAt": a.ends_at,
"upstreamCreatedAt": a.upstream_created_at,
"upstreamUpdatedAt": a.upstream_updated_at,
"firstSeenAt": a.first_seen_at,
"lastSeenAt": a.last_seen_at,
"readAt": a.read_at,
"dismissedAt": a.dismissed_at,
"rawPayload": a.raw_payload,
})
})
.collect();
Ok(Json(Value::Array(paginated)))
}
/// POST /api/site-announcements/{id}/read
async fn mark_read(State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
info!("Marking site announcement {} as read", id);
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// POST /api/site-announcements/read-all
async fn mark_all_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Marking all site announcements as read");
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE read_at IS NULL")
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// DELETE /api/site-announcements
async fn delete_all(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Deleting all site announcements");
sqlx::query("DELETE FROM site_announcements")
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// Parse a datetime string like "2026-05-15 10:30:00" or ISO to milliseconds since epoch.
fn parse_dt_millis(s: &str) -> i64 {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M:%S") {
dt.and_utc().timestamp_millis()
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s.trim()) {
dt.timestamp_millis()
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%dT%H:%M:%S") {
dt.and_utc().timestamp_millis()
} else {
0
}
}
+144
View File
@@ -0,0 +1,144 @@
// Stats routes — usage analytics queries
use crate::{error::AppResult, AppState};
use axum::{
extract::{Query, State},
routing::get,
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct ModelBySiteQuery {
site_id: Option<i64>,
days: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct SiteDistributionQuery {
days: Option<i64>,
refresh: Option<String>,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/stats/model-by-site", get(model_by_site))
.route("/api/stats/site-distribution", get(site_distribution))
}
/// GET /api/stats/model-by-site?siteId=...&days=...
async fn model_by_site(
State(state): State<AppState>,
Query(q): Query<ModelBySiteQuery>,
) -> AppResult<Json<Value>> {
info!(
"Stats model-by-site: site_id={:?}, days={:?}",
q.site_id, q.days
);
let days = q.days.unwrap_or(7).max(1);
// Calculate cutoff date
let since_day = chrono::Utc::now()
.checked_sub_signed(chrono::Duration::days(days - 1))
.unwrap()
.format("%Y-%m-%d")
.to_string();
let rows = if let Some(site_id) = q.site_id {
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
"SELECT model, local_day, total_calls, total_spend, total_tokens
FROM model_day_usage
WHERE site_id = ? AND local_day >= ?
ORDER BY local_day DESC",
)
.bind(site_id)
.bind(&since_day)
.fetch_all(&*state.db)
.await?
} else {
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
"SELECT model, local_day, total_calls, total_spend, total_tokens
FROM model_day_usage
WHERE local_day >= ?
ORDER BY local_day DESC",
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?
};
// Aggregate by model
let mut model_map: std::collections::HashMap<String, (i64, f64, i64)> =
std::collections::HashMap::new();
for (model, _day, calls, spend, tokens) in rows {
let entry = model_map.entry(model).or_insert((0, 0.0, 0));
entry.0 += calls;
entry.1 += spend;
entry.2 += tokens;
}
let mut models: Vec<Value> = model_map
.into_iter()
.map(|(model, (calls, spend, tokens))| {
json!({
"model": model,
"calls": calls,
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
"tokens": tokens,
})
})
.collect();
models.sort_by(|a, b| {
b["calls"]
.as_i64()
.unwrap_or(0)
.cmp(&a["calls"].as_i64().unwrap_or(0))
});
Ok(Json(json!({ "models": models })))
}
/// GET /api/stats/site-distribution?days=...&refresh=...
async fn site_distribution(
State(state): State<AppState>,
Query(q): Query<SiteDistributionQuery>,
) -> AppResult<Json<Value>> {
info!("Stats site-distribution: days={:?}", q.days);
let days = q.days.unwrap_or(7).max(1);
let since_day = chrono::Utc::now()
.checked_sub_signed(chrono::Duration::days(days - 1))
.unwrap()
.format("%Y-%m-%d")
.to_string();
// Sum per-site usage
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0), COALESCE(SUM(m.total_tokens), 0)
FROM sites s
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
GROUP BY s.id, s.name
ORDER BY SUM(m.total_spend) DESC"
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?;
let distribution: Vec<Value> = rows
.into_iter()
.map(|(id, name, calls, spend, tokens)| {
json!({
"siteId": id,
"siteName": name,
"calls": calls,
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
"tokens": tokens,
})
})
.collect();
Ok(Json(json!({ "distribution": distribution })))
}
+180
View File
@@ -0,0 +1,180 @@
// Background tasks routes — list and inspect async operations
use std::convert::Infallible;
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
use axum::{
extract::{Path, Query, State},
response::{
sse::{Event, Sse},
IntoResponse,
},
routing::get,
Json, Router,
};
use futures::stream::{self, Stream};
use serde::Deserialize;
use serde_json::{json, Value};
use tokio_stream::StreamExt;
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct ListTasksQuery {
limit: Option<i64>,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/tasks", get(list_tasks))
.route("/api/tasks/{id}", get(get_task))
.route(
"/api/update-center/tasks/{id}/stream",
get(stream_task_logs),
)
}
/// GET /api/tasks — list background tasks
async fn list_tasks(
State(state): State<AppState>,
Query(q): Query<ListTasksQuery>,
) -> AppResult<Json<Value>> {
let limit = q.limit.unwrap_or(50).max(1).min(500);
info!("Listing background tasks, limit={}", limit);
let tasks: Vec<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
"SELECT * FROM background_tasks ORDER BY created_at DESC LIMIT ?",
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "tasks": tasks })))
}
/// GET /api/tasks/{id} — get a single background task
async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppResult<Json<Value>> {
info!("Getting background task: {}", id);
let task: Option<BackgroundTask> =
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
.bind(&id)
.fetch_optional(&*state.db)
.await?;
match task {
Some(t) => Ok(Json(json!({ "success": true, "task": t }))),
None => Ok(Json(
json!({ "success": false, "message": "task not found" }),
)),
}
}
/// GET /api/update-center/tasks/{id}/stream — SSE stream of task progress
async fn stream_task_logs(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, axum::response::Response> {
info!("Streaming task logs for: {}", id);
// Check task exists
let task: Option<BackgroundTask> =
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
.bind(&id)
.fetch_optional(&*state.db)
.await
.map_err(|e| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("DB error: {e}"),
)
.into_response()
})?;
let task = match task {
Some(t) => t,
None => {
return Err((
axum::http::StatusCode::NOT_FOUND,
json!({ "success": false, "message": "task not found" }).to_string(),
)
.into_response());
}
};
// Stream task state with polling
let initial_status = task.status.clone();
let is_done = initial_status != "pending" && initial_status != "running";
let state_clone = state.clone();
let stream = async_stream::stream! {
// Yield current task status
yield Ok::<_, Infallible>(Event::default()
.event("status")
.data(json!({ "status": initial_status }).to_string()));
yield Ok::<_, Infallible>(Event::default()
.event("log")
.data(json!({
"seq": 1,
"message": format!("Task {}: {}", initial_status, task.message.as_deref().unwrap_or("")),
"createdAt": task.created_at.as_deref().unwrap_or(""),
}).to_string()));
if is_done {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": initial_status }).to_string()));
return;
}
// Poll for updates
let poll_id = id.clone();
for i in 0..120 { // Poll up to 120 times (2 minutes)
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
let current: Option<BackgroundTask> = sqlx::query_as(
"SELECT * FROM background_tasks WHERE id = ?"
)
.bind(&poll_id)
.fetch_optional(&*state_clone.db)
.await
.ok()
.flatten();
match current {
Some(t) => {
let status = t.status;
let updated_at = t.updated_at.unwrap_or_default();
yield Ok::<_, Infallible>(Event::default()
.event("log")
.data(json!({
"seq": i + 2,
"message": format!("Status: {} — {}", status, t.message.as_deref().unwrap_or("")),
"createdAt": updated_at,
}).to_string()));
if status != "pending" && status != "running" {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": status }).to_string()));
return;
}
}
None => {
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": "unknown", "message": "task removed" }).to_string()));
return;
}
}
}
// Timeout
yield Ok::<_, Infallible>(Event::default()
.event("done")
.data(json!({ "status": "timeout" }).to_string()));
};
Ok(Sse::new(stream))
}
+433
View File
@@ -0,0 +1,433 @@
// Update Center routes — deploy/rollback orchestration via helper API
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::State,
routing::{get, post, put},
Json, Router,
};
use serde_json::{json, Value};
const UC_CONFIG_KEY: &str = "update_center_config";
const TASK_TYPE_DEPLOY: &str = "update-center-deploy";
const TASK_TYPE_ROLLBACK: &str = "update-center-rollback";
const DEDUPE_KEY_DEPLOY: &str = "update-center:deploy";
const DEDUPE_KEY_ROLLBACK: &str = "update-center:rollback";
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/update-center/status", get(get_status))
.route("/api/update-center/check", post(check_status))
.route("/api/update-center/config", put(update_config))
.route("/api/update-center/deploy", post(deploy_handler))
.route("/api/update-center/rollback", post(rollback_handler))
}
/// GET /api/update-center/status
async fn get_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let last_deploy = load_last_task(&state, TASK_TYPE_DEPLOY).await?;
let last_rollback = load_last_task(&state, TASK_TYPE_ROLLBACK).await?;
Ok(Json(json!({
"enabled": config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
"helperBaseUrl": config.get("helperBaseUrl"),
"namespace": config.get("namespace"),
"releaseName": config.get("releaseName"),
"helperTokenConfigured": !helper_token.is_empty(),
"lastDeploy": last_deploy,
"lastRollback": last_rollback,
})))
}
/// POST /api/update-center/check — refresh status cache
async fn check_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
// Same as get_status for now
get_status(State(state)).await
}
/// PUT /api/update-center/config
async fn update_config(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let serialized = serde_json::to_string(&body)
.map_err(|e| AppError::BadRequest(format!("Invalid config JSON: {e}")))?;
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(UC_CONFIG_KEY)
.bind(&serialized)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"config": body,
})))
}
/// POST /api/update-center/deploy
async fn deploy_handler(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let helper_base = config
.get("helperBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let namespace = config
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let chart_ref = config
.get("chartRef")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let image_repo = config
.get("imageRepository")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if helper_base.is_empty()
|| namespace.is_empty()
|| chart_ref.is_empty()
|| helper_token.is_empty()
{
return Err(AppError::BadRequest(
"Update center not fully configured".into(),
));
}
let target_tag = body
.get("targetTag")
.or_else(|| body.get("targetVersion"))
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if target_tag.is_empty() {
return Err(AppError::BadRequest("targetTag is required".into()));
}
// Extract owned values for the spawn
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
let namespace_owned = namespace.to_string();
let chart_ref_owned = chart_ref.to_string();
let image_repo_owned = image_repo.to_string();
let release_name = config
.get("releaseName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let target_tag_owned = target_tag.to_string();
let helper_token_owned = helper_token;
let title = format!("部署 {}", target_tag);
let task_id = uuid::Uuid::new_v4().to_string();
// Create background task
sqlx::query(
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
VALUES (?, ?, ?, 'running', ?, ?)",
)
.bind(&task_id)
.bind(TASK_TYPE_DEPLOY)
.bind(&title)
.bind(format!(
"Starting deploy of {} to {}/{}",
target_tag, namespace, chart_ref
))
.bind(DEDUPE_KEY_DEPLOY)
.execute(&*state.db)
.await?;
// Call deploy helper in background (fire-and-forget via tokio)
let client = reqwest::Client::new();
let deploy_url = format!("{}/api/deploy", helper_base_owned);
let task_id_clone = task_id.clone();
let db_pool = state.db.clone();
tokio::spawn(async move {
let payload = json!({
"namespace": namespace_owned,
"releaseName": release_name,
"chartRef": chart_ref_owned,
"imageRepository": image_repo_owned,
"targetTag": target_tag_owned,
});
match client
.post(&deploy_url)
.header("Authorization", format!("Bearer {}", helper_token_owned))
.json(&payload)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let body_text = resp.text().await.unwrap_or_default();
if status.is_success() {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Deploy completed (HTTP {})", status_code))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!(
"Helper returned HTTP {}: {}",
status_code, body_text
))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Helper unreachable: {}", e))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
});
Ok(Json(json!({
"success": true,
"reused": false,
"task": {
"id": task_id,
"type": TASK_TYPE_DEPLOY,
"title": title,
"status": "running",
},
})))
}
/// POST /api/update-center/rollback
async fn rollback_handler(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let helper_base = config
.get("helperBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let namespace = config
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if helper_base.is_empty() || namespace.is_empty() || helper_token.is_empty() {
return Err(AppError::BadRequest(
"Update center not fully configured".into(),
));
}
let target_revision = body
.get("targetRevision")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if target_revision.is_empty() {
return Err(AppError::BadRequest("targetRevision is required".into()));
}
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
let namespace_owned = namespace.to_string();
let release_name = config
.get("releaseName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let target_revision_owned = target_revision.to_string();
let helper_token_owned = helper_token;
let title = format!("回退 {}", target_revision);
let task_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
VALUES (?, ?, ?, 'running', ?, ?)",
)
.bind(&task_id)
.bind(TASK_TYPE_ROLLBACK)
.bind(&title)
.bind(format!(
"Starting rollback to {} in {}",
target_revision, namespace
))
.bind(DEDUPE_KEY_ROLLBACK)
.execute(&*state.db)
.await?;
let client = reqwest::Client::new();
let rollback_url = format!("{}/api/rollback", helper_base_owned);
let task_id_clone = task_id.clone();
let db_pool = state.db.clone();
tokio::spawn(async move {
let payload = json!({
"namespace": namespace_owned,
"releaseName": release_name,
"targetRevision": target_revision_owned,
});
match client
.post(&rollback_url)
.header("Authorization", format!("Bearer {}", helper_token_owned))
.json(&payload)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let body_text = resp.text().await.unwrap_or_default();
if status.is_success() {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Rollback completed (HTTP {})", status_code))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!(
"Helper returned HTTP {}: {}",
status_code, body_text
))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Helper unreachable: {}", e))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
});
Ok(Json(json!({
"success": true,
"reused": false,
"task": {
"id": task_id,
"type": TASK_TYPE_ROLLBACK,
"title": title,
"status": "running",
},
})))
}
// ── Helpers ────────────────────────────────────────────────────────────
fn get_helper_token() -> String {
std::env::var("UPDATE_CENTER_HELPER_TOKEN")
.or_else(|_| std::env::var("DEPLOY_HELPER_TOKEN"))
.unwrap_or_default()
.trim()
.to_string()
}
async fn load_uc_config(state: &AppState) -> AppResult<Value> {
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(UC_CONFIG_KEY)
.fetch_optional(&*state.db)
.await?;
match row {
Some((v,)) => serde_json::from_str(&v)
.map_err(|e| AppError::BadRequest(format!("Invalid update center config JSON: {e}"))),
None => Ok(json!({})),
}
}
async fn load_last_task(state: &AppState, task_type: &str) -> AppResult<Option<Value>> {
let row: Option<(
String,
String,
Option<String>,
Option<String>,
String,
Option<String>,
Option<String>,
)> = sqlx::query_as(
"SELECT id, status, message, error, created_at, started_at, finished_at
FROM background_tasks
WHERE task_type = ?
ORDER BY created_at DESC
LIMIT 1",
)
.bind(task_type)
.fetch_optional(&*state.db)
.await?;
Ok(row.map(
|(id, status, message, error, created_at, started_at, finished_at)| {
json!({
"id": id,
"type": task_type,
"status": status,
"message": message,
"error": error,
"createdAt": created_at,
"startedAt": started_at,
"finishedAt": finished_at,
})
},
))
}
+160
View File
@@ -0,0 +1,160 @@
use crate::{
auth::jwt::validate_jwt,
db::models::{downstream_api_key::DownstreamApiKey, user::User},
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{delete, get},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
pub fn routes() -> Router<AppState> {
Router::new()
.route("/v1/user/profile", get(get_profile).put(update_profile))
.route("/v1/user/api-keys", get(list_api_keys).post(create_api_key))
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
}
#[derive(Debug, Deserialize)]
struct UpdateProfilePayload {
username: Option<String>,
email: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CreateApiKeyPayload {
name: String,
description: Option<String>,
expires_at: Option<String>,
max_requests: Option<i64>,
max_cost: Option<f64>,
}
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
}
async fn get_profile(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
Ok(Json(json!({ "user": user })))
}
async fn update_profile(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UpdateProfilePayload>,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let current: User = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
let username = payload.username.unwrap_or(current.username);
let email = payload.email.unwrap_or(current.email);
sqlx::query(
"UPDATE users
SET username = ?, email = ?, updated_at = datetime('now')
WHERE id = ?",
)
.bind(username)
.bind(email)
.bind(user_id)
.execute(&*state.db)
.await?;
get_profile(State(state), headers).await
}
async fn list_api_keys(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let keys: Vec<DownstreamApiKey> = sqlx::query_as(
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(user_id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "keys": keys })))
}
async fn create_api_key(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateApiKeyPayload>,
) -> AppResult<(StatusCode, Json<Value>)> {
let user_id = current_user_id(&headers, &state)?;
if payload.name.trim().is_empty() {
return Err(AppError::BadRequest("name is required".into()));
}
let token = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
let result = sqlx::query(
"INSERT INTO downstream_api_keys
(name, key, description, enabled, expires_at, max_requests, max_cost, user_id)
VALUES (?, ?, ?, 1, ?, ?, ?, ?)",
)
.bind(payload.name)
.bind(&token)
.bind(payload.description)
.bind(payload.expires_at)
.bind(payload.max_requests)
.bind(payload.max_cost)
.bind(user_id)
.execute(&*state.db)
.await?;
let key: DownstreamApiKey = sqlx::query_as("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "key": key }))))
}
async fn delete_api_key(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> AppResult<StatusCode> {
let user_id = current_user_id(&headers, &state)?;
let result = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
.bind(id)
.bind(user_id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("API key not found".into()));
}
Ok(StatusCode::NO_CONTENT)
}
+270
View File
@@ -0,0 +1,270 @@
// User-level API key routes — JWT-authenticated CRUD scoped to current user.
// Backed by the same downstream_api_keys table as the admin route.
use crate::{
auth::jwt::validate_jwt,
db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{delete, get, patch, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/user-api-keys",
get(list_user_keys).post(create_user_key),
)
.route(
"/api/user-api-keys/{id}",
patch(update_user_key).delete(delete_user_key),
)
}
/// Extract user_id and role from JWT in Authorization header.
fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, String)> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let user_id: i64 = claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
Ok((user_id, claims.role))
}
/// GET /api/user-api-keys — list keys for the authenticated user
async fn list_user_keys(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
info!("Listing user API keys for user {}", user_id);
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(user_id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "keys": keys })))
}
/// POST /api/user-api-keys — create a new API key for the authenticated user
async fn create_user_key(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> AppResult<(StatusCode, Json<Value>)> {
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
info!("Creating user API key for user {}", user_id);
let name = body
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("name is required".into()))?;
let description = body.get("description").and_then(|v| v.as_str());
let tags = body.get("tags").map(|v| v.to_string());
// Generate key: sk-user-<uuid>
let key_str = body
.get("key")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
format!(
"sk-user-{}",
uuid::Uuid::new_v4().to_string().replace('-', "")
)
});
let enabled = body
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let expires_at = body.get("expiresAt").and_then(|v| v.as_str());
let max_cost = body.get("maxCost").and_then(|v| v.as_f64());
let max_requests = body.get("maxRequests").and_then(|v| v.as_i64());
let supported_models = body.get("supportedModels").map(|v| v.to_string());
let allowed_route_ids = body.get("allowedRouteIds").map(|v| v.to_string());
let site_weight_multipliers = body.get("siteWeightMultipliers").map(|v| v.to_string());
let excluded_site_ids = body.get("excludedSiteIds").map(|v| v.to_string());
let excluded_credential_refs = body.get("excludedCredentialRefs").map(|v| v.to_string());
let result = sqlx::query(
r#"INSERT INTO downstream_api_keys
(name, key, description, tags, enabled, expires_at, max_cost, max_requests,
supported_models, allowed_route_ids, site_weight_multipliers,
excluded_site_ids, excluded_credential_refs, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
)
.bind(name)
.bind(&key_str)
.bind(description)
.bind(tags)
.bind(enabled)
.bind(expires_at)
.bind(max_cost)
.bind(max_requests)
.bind(supported_models)
.bind(allowed_route_ids)
.bind(site_weight_multipliers)
.bind(excluded_site_ids)
.bind(excluded_credential_refs)
.bind(user_id)
.execute(&*state.db)
.await?;
let new_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "key": new_key }))))
}
/// PATCH /api/user-api-keys/{id} — update a key owned by the authenticated user
async fn update_user_key(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
info!("Updating user API key {} for user {}", id, user_id);
// Verify ownership
let existing: Option<DownstreamApiKey> =
sqlx::query_as("SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?")
.bind(id)
.bind(user_id)
.fetch_optional(&*state.db)
.await?;
if existing.is_none() {
return Err(AppError::NotFound("Key not found".into()));
}
// Build dynamic UPDATE
let mut sets: Vec<String> = vec![];
let mut params: Vec<String> = vec![];
let mut bind_idx = 0;
if let Some(v) = body.get("name").and_then(|v| v.as_str()) {
bind_idx += 1;
sets.push(format!("name = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("description") {
bind_idx += 1;
sets.push(format!("description = ?{}", bind_idx));
params.push(v.as_str().unwrap_or_default().to_string());
}
if let Some(v) = body.get("tags") {
bind_idx += 1;
sets.push(format!("tags = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) {
bind_idx += 1;
sets.push(format!("enabled = ?{}", bind_idx));
params.push(if v { "1" } else { "0" }.to_string());
}
if let Some(v) = body.get("expiresAt").and_then(|v| v.as_str()) {
bind_idx += 1;
sets.push(format!("expires_at = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("maxCost").and_then(|v| v.as_f64()) {
bind_idx += 1;
sets.push(format!("max_cost = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("maxRequests").and_then(|v| v.as_i64()) {
bind_idx += 1;
sets.push(format!("max_requests = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("supportedModels") {
bind_idx += 1;
sets.push(format!("supported_models = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("allowedRouteIds") {
bind_idx += 1;
sets.push(format!("allowed_route_ids = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("excludedSiteIds") {
bind_idx += 1;
sets.push(format!("excluded_site_ids = ?{}", bind_idx));
params.push(v.to_string());
}
if !sets.is_empty() {
bind_idx += 1;
sets.push(format!("updated_at = ?{}", bind_idx));
params.push(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string());
let sql = format!(
"UPDATE downstream_api_keys SET {} WHERE id = ?{} AND user_id = ?{}",
sets.join(", "),
bind_idx + 1,
bind_idx + 2
);
let mut q = sqlx::query(&sql);
for p in &params {
q = q.bind(p);
}
q = q.bind(id).bind(user_id);
q.execute(&*state.db).await?;
}
let updated: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "key": updated })))
}
/// DELETE /api/user-api-keys/{id} — delete a key owned by the authenticated user
async fn delete_user_key(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> AppResult<StatusCode> {
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
info!("Deleting user API key {} for user {}", id, user_id);
let result = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
.bind(id)
.bind(user_id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Key not found".into()));
}
Ok(StatusCode::NO_CONTENT)
}
+517
View File
@@ -0,0 +1,517 @@
use axum::{
body::Body,
extract::State,
http::{header, HeaderMap, Response, StatusCode},
response::IntoResponse,
routing::post,
Json, Router,
};
use serde_json::{json, Value};
use tracing::info;
use crate::{
error::{AppError, AppResult},
AppState,
};
const SCRIPT_ANALYSIS_PROMPT: &str = r#"你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
{
"title": "视频标题",
"characters": [
{
"name": "角色名",
"gender": "男/女",
"age": "年龄描述",
"appearance": "外貌详细描述(发型、脸型、服装、体型等)",
"personality": "性格特征"
}
],
"scenes": [
{
"id": 1,
"description": "场景描述",
"environment": "环境详细描述(时间、地点、天气、光线等)",
"characters": ["出现的角色名"],
"action": "场景中发生的动作",
"dialogue": [
{"character": "角色名", "line": "台词", "emotion": "情感语气"}
]
}
],
"style": "整体视觉风格(动画/写实/像素等)"
}
只返回 JSON,不要其他文字。"#;
const CHARACTER_PROMPT_GEN: &str = r#"根据角色描述生成 4 组稳定的图像生成提示词(正面、背面、左侧面、右侧面),用于 AI 图像生成模型。
每组提示词应包含:人物姿态、服装、光照、背景。
返回 JSON 格式:
{
"front": "正面提示词",
"back": "背面提示词",
"left": "左侧面提示词",
"right": "右侧面提示词"
}
只返回 JSON。"#;
const SCENE_PROMPT_GEN: &str = r#"根据场景描述生成图像生成提示词,用于 AI 图像生成模型创建场景背景。
包含:环境、光照、色调、氛围。
返回 JSON:
{
"prompt": "场景生成提示词",
"negative_prompt": "负面提示词"
}
只返回 JSON。"#;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/video-agent/analyze", post(analyze))
.route(
"/api/video-agent/character-prompts",
post(character_prompts),
)
.route(
"/api/video-agent/generate-character",
post(generate_character),
)
.route("/api/video-agent/scene-prompt", post(scene_prompt))
.route("/api/video-agent/generate-scene", post(generate_scene))
.route("/api/video-agent/generate-tts", post(generate_tts))
.route("/api/video-agent/full-workflow", post(full_workflow))
}
fn upstream_base() -> String {
std::env::var("BOOSAPI_UPSTREAM_BASE")
.unwrap_or_else(|_| "http://127.0.0.1:4000/v1".to_string())
.trim_end_matches('/')
.to_string()
}
fn upstream_key() -> String {
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
}
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let expected = format!("Bearer {}", state.config.auth.auth_token);
let actual = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if actual != expected {
return Err(AppError::BadRequest("unauthorized".to_string()));
}
Ok(())
}
fn required_str<'a>(body: &'a Value, key: &str) -> AppResult<&'a str> {
body.get(key)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
}
fn required_value<'a>(body: &'a Value, key: &str) -> AppResult<&'a Value> {
body.get(key)
.filter(|v| !v.is_null())
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
}
fn strip_json_markers(s: &str) -> String {
let mut text = s.trim().to_string();
if text.starts_with("```json") {
text = text.trim_start_matches("```json").to_string();
} else if text.starts_with("```") {
text = text.trim_start_matches("```").to_string();
}
if text.ends_with("```") {
text = text.trim_end_matches("```").to_string();
}
text.trim().to_string()
}
fn parse_llm_json(raw: &str) -> AppResult<Value> {
let cleaned = strip_json_markers(raw);
serde_json::from_str::<Value>(&cleaned).map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"failed to parse LLM JSON response: {}; response: {}",
e,
cleaned
))
})
}
async fn call_llm(prompt: &str, system: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", upstream_base());
info!("video-agent calling LLM upstream: {}", url);
let resp = client
.post(url)
.bearer_auth(upstream_key())
.json(&json!({
"model": "gpt-5.5",
"messages": [
{ "role": "system", "content": system },
{ "role": "user", "content": prompt }
],
"temperature": 0.7
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM request failed: {}", e)))?;
let status = resp.status();
let data: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM response parse failed: {}", e)))?;
if !status.is_success() {
return Err(AppError::Internal(anyhow::anyhow!(
"LLM upstream returned {}: {}",
status,
data
)));
}
Ok(data
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
async fn call_image_gen(prompt: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/images/generations", upstream_base());
info!("video-agent calling image upstream: {}", url);
let resp = client
.post(url)
.bearer_auth(upstream_key())
.json(&json!({
"model": "gpt-image-2",
"prompt": prompt,
"n": 1,
"size": "1024x1024"
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("image request failed: {}", e)))?;
let status = resp.status();
let data: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("image response parse failed: {}", e)))?;
if !status.is_success() {
return Err(AppError::Internal(anyhow::anyhow!(
"image upstream returned {}: {}",
status,
data
)));
}
Ok(data
.get("data")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("url"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
async fn call_tts(text: &str, voice: &str) -> AppResult<Vec<u8>> {
let client = reqwest::Client::new();
let url = format!("{}/audio/speech", upstream_base());
info!("video-agent calling TTS upstream: {}", url);
let resp = client
.post(url)
.bearer_auth(upstream_key())
.json(&json!({
"model": "tts-1",
"input": text,
"voice": voice
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS request failed: {}", e)))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError::Internal(anyhow::anyhow!(
"TTS upstream returned {}: {}",
status,
body
)));
}
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS response read failed: {}", e)))?;
Ok(bytes.to_vec())
}
async fn analyze(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let script = required_str(&body, "script")?;
let result = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
let parsed = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": parsed
})))
}
async fn character_prompts(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let character = required_value(&body, "character")?;
let name = character
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let appearance = character
.get("appearance")
.and_then(Value::as_str)
.unwrap_or_default();
let gender = character
.get("gender")
.and_then(Value::as_str)
.unwrap_or_default();
let age = character
.get("age")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{name}: {appearance}, {gender}, {age}");
let result = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
let prompts = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": prompts
})))
}
async fn generate_character(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let prompts = required_value(&body, "prompts")?;
let prompt_obj = prompts
.as_object()
.ok_or_else(|| AppError::BadRequest("prompts must be an object".to_string()))?;
let mut results = serde_json::Map::new();
for (angle, prompt_value) in prompt_obj {
let prompt = prompt_value.as_str().ok_or_else(|| {
AppError::BadRequest(format!("prompt for angle '{angle}' must be a string"))
})?;
let url = call_image_gen(prompt).await?;
results.insert(angle.clone(), Value::String(url));
}
Ok(Json(json!({
"success": true,
"data": Value::Object(results)
})))
}
async fn scene_prompt(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let scene = required_value(&body, "scene")?;
let environment = scene
.get("environment")
.and_then(Value::as_str)
.unwrap_or_default();
let description = scene
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{environment}. {description}");
let result = call_llm(&desc, SCENE_PROMPT_GEN).await?;
let prompt = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": prompt
})))
}
async fn generate_scene(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let prompt = required_str(&body, "prompt")?;
let url = call_image_gen(prompt).await?;
Ok(Json(json!({
"success": true,
"data": {
"url": url
}
})))
}
async fn generate_tts(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Response<Body>> {
check_auth(&headers, &state)?;
let text = required_str(&body, "text")?;
let voice = body
.get("voice")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.unwrap_or("alloy");
let audio = call_tts(text, voice).await?;
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "audio/mpeg")],
audio,
)
.into_response())
}
async fn full_workflow(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let script = required_str(&body, "script")?;
let requested_style = body.get("style").and_then(Value::as_str);
let analysis_raw = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
let analysis = parse_llm_json(&analysis_raw)?;
let mut characters = Vec::new();
if let Some(chars) = analysis.get("characters").and_then(Value::as_array) {
for character in chars {
let name = character
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let appearance = character
.get("appearance")
.and_then(Value::as_str)
.unwrap_or_default();
let gender = character
.get("gender")
.and_then(Value::as_str)
.unwrap_or_default();
let age = character
.get("age")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{name}: {appearance}, {gender}, {age}");
let prompts_raw = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
let prompts = parse_llm_json(&prompts_raw)?;
let mut item = character.clone();
if let Some(obj) = item.as_object_mut() {
obj.insert("imagePrompts".to_string(), prompts);
}
characters.push(item);
}
}
let mut scenes = Vec::new();
if let Some(scene_items) = analysis.get("scenes").and_then(Value::as_array) {
for scene in scene_items {
let environment = scene
.get("environment")
.and_then(Value::as_str)
.unwrap_or_default();
let description = scene
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{environment}. {description}");
let scene_prompt_raw = call_llm(&desc, SCENE_PROMPT_GEN).await?;
let image_prompt = parse_llm_json(&scene_prompt_raw)?;
let mut item = scene.clone();
if let Some(obj) = item.as_object_mut() {
obj.insert("imagePrompt".to_string(), image_prompt);
}
scenes.push(item);
}
}
let style = analysis
.get("style")
.cloned()
.or_else(|| requested_style.map(|s| Value::String(s.to_string())))
.unwrap_or(Value::Null);
Ok(Json(json!({
"success": true,
"data": {
"title": analysis.get("title").cloned().unwrap_or(Value::Null),
"style": style,
"characters": characters,
"scenes": scenes
}
})))
}