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
+104
View File
@@ -0,0 +1,104 @@
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use sqlx::SqlitePool;
use crate::db::models::downstream_api_key::DownstreamApiKey;
/// Result of downstream token authorization.
#[derive(Debug, Clone)]
pub enum DownstreamAuthResult {
/// Found in downstream_api_keys table.
Managed { key: DownstreamApiKey },
/// Matched the global proxy token from config.
Global { token: String },
}
/// Authorize a downstream token (sk-xxx or global proxy token).
///
/// Looks up the token in:
/// 1. downstream_api_keys table (for managed keys)
/// 2. Falls back to checking against the global proxy token
pub async fn authorize_downstream_token(
token: &str,
pool: &SqlitePool,
) -> Result<DownstreamAuthResult, String> {
// Try managed keys first
if token.starts_with("sk-") {
let key: Option<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE key = ? AND enabled = 1",
)
.bind(token)
.fetch_optional(pool)
.await
.map_err(|e| format!("Database error: {e}"))?;
if let Some(k) = key {
return Ok(DownstreamAuthResult::Managed { key: k });
}
// Key not found in table
return Err("Invalid API key".to_string());
}
// Not an sk- key, can't be a downstream key
Err("Invalid API key format".to_string())
}
/// Generate 16 random bytes for a salt using `uuid::Uuid::new_v4()`.
fn random_salt_bytes() -> [u8; 16] {
*uuid::Uuid::new_v4().as_bytes()
}
/// Hash an API key using Argon2id with a random salt.
pub fn hash_api_key(key: &str) -> anyhow::Result<String> {
let salt = SaltString::encode_b64(&random_salt_bytes())
.map_err(|e| anyhow::anyhow!("Failed to generate salt: {e}"))?;
let hash = Argon2::default()
.hash_password(key.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("Failed to hash API key: {e}"))?;
Ok(hash.to_string())
}
/// Verify an API key against an Argon2id hash.
pub fn verify_api_key(key: &str, hash: &str) -> anyhow::Result<bool> {
let parsed_hash =
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("Invalid hash format: {e}"))?;
Ok(Argon2::default()
.verify_password(key.as_bytes(), &parsed_hash)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_api_key() {
let key = "sk-test-api-key-12345";
let hash = hash_api_key(key).unwrap();
assert!(verify_api_key(key, &hash).unwrap());
}
#[test]
fn test_wrong_key_fails() {
let key = "sk-test-api-key-12345";
let hash = hash_api_key(key).unwrap();
assert!(!verify_api_key("wrong-key", &hash).unwrap());
}
#[test]
fn test_invalid_hash_fails() {
let result = verify_api_key("some-key", "not-a-valid-hash");
assert!(result.is_err());
}
#[test]
fn test_different_hashes_for_same_key() {
let key = "sk-test-key";
let hash1 = hash_api_key(key).unwrap();
let hash2 = hash_api_key(key).unwrap();
assert_ne!(hash1, hash2);
assert!(verify_api_key(key, &hash1).unwrap());
assert!(verify_api_key(key, &hash2).unwrap());
}
}
+70
View File
@@ -0,0 +1,70 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // user_id
pub role: String, // "admin" | "user"
pub exp: usize, // expiry timestamp
pub iat: usize, // issued at
}
pub fn generate_jwt(
secret: &str,
user_id: &str,
role: &str,
expiry_hours: i64,
) -> anyhow::Result<String> {
let now = Utc::now();
let claims = Claims {
sub: user_id.to_string(),
role: role.to_string(),
iat: now.timestamp() as usize,
exp: (now + Duration::hours(expiry_hours)).timestamp() as usize,
};
Ok(encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)?)
}
pub fn validate_jwt(secret: &str, token: &str) -> anyhow::Result<Claims> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)?;
Ok(data.claims)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_and_validate_jwt() {
let secret = "test-secret-key";
let token = generate_jwt(secret, "user-123", "admin", 24).unwrap();
let claims = validate_jwt(secret, &token).unwrap();
assert_eq!(claims.sub, "user-123");
assert_eq!(claims.role, "admin");
assert!(claims.iat > 0);
assert!(claims.exp > claims.iat);
}
#[test]
fn test_invalid_token_fails() {
let secret = "test-secret-key";
let result = validate_jwt(secret, "invalid-token");
assert!(result.is_err());
}
#[test]
fn test_wrong_secret_fails() {
let token = generate_jwt("secret1", "user-123", "user", 24).unwrap();
let result = validate_jwt("secret2", &token);
assert!(result.is_err());
}
}
+16
View File
@@ -0,0 +1,16 @@
// Auth module — JWT, API key, and password authentication
pub mod api_key;
pub mod jwt;
pub mod password;
/// Represents the authentication context for a request.
#[derive(Debug, Clone)]
pub enum AuthContext {
/// Admin token-based authentication.
Admin { token: String },
/// Authenticated user session with JWT.
User { user_id: String, role: String },
/// Downstream API key authentication.
DownstreamKey { key_id: i64, name: String },
}
+62
View File
@@ -0,0 +1,62 @@
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
/// Generate 16 random bytes for a salt using `uuid::Uuid::new_v4()`.
fn random_salt_bytes() -> [u8; 16] {
*uuid::Uuid::new_v4().as_bytes()
}
/// Hash a user password using Argon2id with a random salt.
pub fn hash_password(password: &str) -> anyhow::Result<String> {
let salt = SaltString::encode_b64(&random_salt_bytes())
.map_err(|e| anyhow::anyhow!("Failed to generate salt: {e}"))?;
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("Failed to hash password: {e}"))?;
Ok(hash.to_string())
}
/// Verify a password against an Argon2id hash.
pub fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
let parsed_hash =
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("Invalid hash format: {e}"))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_password() {
let password = "secure-password-123!";
let hash = hash_password(password).unwrap();
assert!(verify_password(password, &hash).unwrap());
}
#[test]
fn test_wrong_password_fails() {
let password = "correct-password";
let hash = hash_password(password).unwrap();
assert!(!verify_password("wrong-password", &hash).unwrap());
}
#[test]
fn test_different_hashes_for_same_password() {
let password = "my-password";
let hash1 = hash_password(password).unwrap();
let hash2 = hash_password(password).unwrap();
assert_ne!(hash1, hash2);
assert!(verify_password(password, &hash1).unwrap());
assert!(verify_password(password, &hash2).unwrap());
}
#[test]
fn test_empty_password() {
let password = "";
let hash = hash_password(password).unwrap();
assert!(verify_password(password, &hash).unwrap());
}
}