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
+544
View File
@@ -0,0 +1,544 @@
use crate::error::AppResult;
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
use lettre::{
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
Transport,
};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::SqlitePool;
use std::{collections::HashMap, sync::Arc};
#[derive(Clone)]
pub struct NotificationService {
pub db: Arc<SqlitePool>,
pub client: reqwest::Client,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum NotificationLevel {
Info,
Warning,
Error,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Notification {
pub title: String,
pub message: String,
pub level: NotificationLevel,
pub channel: Option<String>,
pub signature: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct NotificationConfig {
telegram_bot_token: Option<String>,
telegram_chat_id: Option<String>,
telegram_thread_id: Option<i64>,
wecom_webhook: Option<String>,
feishu_webhook: Option<String>,
webhook_url: Option<String>,
bark_url: Option<String>,
serverchan_key: Option<String>,
smtp: SmtpConfig,
cooldown_sec: i64,
}
#[derive(Debug, Clone, Default)]
struct SmtpConfig {
host: Option<String>,
port: Option<u16>,
user: Option<String>,
pass: Option<String>,
from: Option<String>,
to: Option<String>,
}
impl NotificationService {
pub fn new(db: Arc<SqlitePool>) -> Self {
Self {
db,
client: reqwest::Client::new(),
}
}
pub fn with_client(db: Arc<SqlitePool>, client: reqwest::Client) -> Self {
Self { db, client }
}
pub async fn send(&self, notif: Notification) -> AppResult<()> {
Self::ensure_schema(&self.db).await?;
let config = self.load_config().await?;
if self.should_throttle(&notif, config.cooldown_sec).await? {
return Ok(());
}
let mut channels = self.enabled_channels(&config);
if let Some(channel) = notif.channel.as_deref() {
channels.retain(|candidate| candidate == &normalize_channel(channel));
}
if channels.is_empty() {
tracing::debug!(
channel = notif.channel.as_deref().unwrap_or("all"),
"notification skipped because no matching channel is configured"
);
return Ok(());
}
for channel in channels {
if let Err(err) = self.send_channel(&channel, &notif, &config).await {
tracing::warn!(%err, channel, "notification channel failed");
}
}
Ok(())
}
async fn load_config(&self) -> AppResult<NotificationConfig> {
let rows: Vec<(String, Option<String>)> =
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
.fetch_all(&*self.db)
.await?;
let map: HashMap<String, String> = rows
.into_iter()
.filter_map(|(key, value)| value.map(|value| (key, value)))
.filter(|(_, value)| !value.trim().is_empty())
.collect();
Ok(NotificationConfig {
telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
telegram_thread_id: setting(&map, "notify_telegram_thread_id")
.and_then(|value| value.parse().ok()),
wecom_webhook: setting(&map, "notify_wecom_webhook"),
feishu_webhook: setting(&map, "notify_feishu_webhook"),
webhook_url: setting(&map, "notify_webhook_url"),
bark_url: setting(&map, "notify_bark_url"),
serverchan_key: setting(&map, "notify_serverchan_key"),
smtp: SmtpConfig {
host: setting(&map, "notify_smtp_host"),
port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()),
user: setting(&map, "notify_smtp_user"),
pass: setting(&map, "notify_smtp_pass"),
from: setting(&map, "notify_smtp_from"),
to: setting(&map, "notify_smtp_to"),
},
cooldown_sec: setting(&map, "notify_cooldown_sec")
.and_then(|value| value.parse().ok())
.unwrap_or(60),
})
}
async fn should_throttle(&self, notif: &Notification, cooldown_sec: i64) -> AppResult<bool> {
let Some(signature) = notif.signature.as_deref().map(str::trim) else {
return Ok(false);
};
if signature.is_empty() || cooldown_sec <= 0 {
return Ok(false);
}
let updated = sqlx::query(
"UPDATE notification_throttle
SET count = count + 1,
title = ?,
message = ?,
level = ?,
updated_at = datetime('now')
WHERE signature = ?
AND updated_at >= datetime('now', ?)",
)
.bind(&notif.title)
.bind(&notif.message)
.bind(level_name(&notif.level))
.bind(signature)
.bind(format!("-{} seconds", cooldown_sec))
.execute(&*self.db)
.await?;
if updated.rows_affected() > 0 {
tracing::info!(signature, "notification merged into cooldown throttle");
return Ok(true);
}
sqlx::query(
"INSERT INTO notification_throttle
(signature, title, message, level, count, updated_at)
VALUES (?, ?, ?, ?, 1, datetime('now'))
ON CONFLICT(signature) DO UPDATE SET
title = excluded.title,
message = excluded.message,
level = excluded.level,
count = 1,
updated_at = datetime('now')",
)
.bind(signature)
.bind(&notif.title)
.bind(&notif.message)
.bind(level_name(&notif.level))
.execute(&*self.db)
.await?;
Ok(false)
}
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
let mut channels = Vec::new();
if config.telegram_bot_token.is_some() && config.telegram_chat_id.is_some() {
channels.push("telegram".to_string());
}
if config.wecom_webhook.is_some() {
channels.push("wecom".to_string());
}
if config.feishu_webhook.is_some() {
channels.push("feishu".to_string());
}
if config.webhook_url.is_some() {
channels.push("webhook".to_string());
}
if config.bark_url.is_some() {
channels.push("bark".to_string());
}
if config.serverchan_key.is_some() {
channels.push("serverchan".to_string());
}
if config.smtp.is_configured() {
channels.push("smtp".to_string());
}
channels
}
async fn send_channel(
&self,
channel: &str,
notif: &Notification,
config: &NotificationConfig,
) -> anyhow::Result<()> {
match channel {
"telegram" => self.send_telegram(notif, config).await,
"wecom" => self.send_wecom(notif, config).await,
"feishu" => self.send_feishu(notif, config).await,
"webhook" => self.send_webhook(notif, config).await,
"bark" => self.send_bark(notif, config).await,
"serverchan" => self.send_serverchan(notif, config).await,
"smtp" => self.send_smtp(notif, config).await,
_ => Ok(()),
}
}
async fn send_telegram(
&self,
notif: &Notification,
config: &NotificationConfig,
) -> anyhow::Result<()> {
let token = required(config.telegram_bot_token.as_deref(), "telegram token")?;
let chat_id = required(config.telegram_chat_id.as_deref(), "telegram chat id")?;
let mut body = json!({
"chat_id": chat_id,
"text": truncate_chars(&format_notification_text(notif), 3900),
});
if let Some(thread_id) = config.telegram_thread_id {
body["message_thread_id"] = json!(thread_id);
}
self.client
.post(format!("https://api.telegram.org/bot{token}/sendMessage"))
.json(&body)
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn send_wecom(
&self,
notif: &Notification,
config: &NotificationConfig,
) -> anyhow::Result<()> {
let url = required(config.wecom_webhook.as_deref(), "wecom webhook")?;
self.client
.post(url)
.json(&json!({
"msgtype": "text",
"text": { "content": format_notification_text(notif) },
}))
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn send_feishu(
&self,
notif: &Notification,
config: &NotificationConfig,
) -> anyhow::Result<()> {
let url = required(config.feishu_webhook.as_deref(), "feishu webhook")?;
self.client
.post(url)
.json(&json!({
"msg_type": "text",
"content": { "text": format_notification_text(notif) },
}))
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn send_webhook(
&self,
notif: &Notification,
config: &NotificationConfig,
) -> anyhow::Result<()> {
let url = required(config.webhook_url.as_deref(), "webhook url")?;
self.client
.post(url)
.json(&json!({
"title": &notif.title,
"message": &notif.message,
"level": level_name(&notif.level),
}))
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn send_bark(
&self,
notif: &Notification,
config: &NotificationConfig,
) -> anyhow::Result<()> {
let base = required(config.bark_url.as_deref(), "bark url")?;
let mut url = Url::parse(base)?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| anyhow::anyhow!("bark url cannot be a base url"))?;
segments.pop_if_empty();
segments.push(&notif.title);
segments.push(&notif.message);
}
self.client.get(url).send().await?.error_for_status()?;
Ok(())
}
async fn send_serverchan(
&self,
notif: &Notification,
config: &NotificationConfig,
) -> anyhow::Result<()> {
let key = required(config.serverchan_key.as_deref(), "serverchan key")?;
self.client
.post(format!("https://sctapi.ftqq.com/{key}.send"))
.form(&[
("title", notif.title.as_str()),
("desp", notif.message.as_str()),
])
.send()
.await?
.error_for_status()?;
Ok(())
}
async fn send_smtp(
&self,
notif: &Notification,
config: &NotificationConfig,
) -> anyhow::Result<()> {
let smtp = config.smtp.clone();
let notif = notif.clone();
tokio::task::spawn_blocking(move || smtp.send(&notif)).await??;
Ok(())
}
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS notification_throttle (
signature TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
level TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(db)
.await?;
Ok(())
}
}
impl SmtpConfig {
fn is_configured(&self) -> bool {
self.host.is_some()
&& self.port.is_some()
&& self.user.is_some()
&& self.pass.is_some()
&& self.from.is_some()
}
fn send(self, notif: &Notification) -> anyhow::Result<()> {
let host = required(self.host.as_deref(), "smtp host")?;
let port = self.port.unwrap_or(587);
let user = required(self.user.as_deref(), "smtp user")?;
let pass = required(self.pass.as_deref(), "smtp pass")?;
let from = required(self.from.as_deref(), "smtp from")?;
let to = self.to.as_deref().unwrap_or(from);
let email = Message::builder()
.from(from.parse::<Mailbox>()?)
.to(to.parse::<Mailbox>()?)
.subject(format!(
"[BoosAPI][{}] {}",
level_name(&notif.level),
notif.title
))
.body(format_notification_text(notif))?;
let mailer = SmtpTransport::relay(host)?
.port(port)
.credentials(Credentials::new(user.to_string(), pass.to_string()))
.build();
mailer.send(&email)?;
Ok(())
}
}
fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
map.get(key).map(|value| value.trim().to_string())
}
fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> {
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
}
fn normalize_channel(channel: &str) -> String {
channel.trim().to_ascii_lowercase()
}
fn level_name(level: &NotificationLevel) -> &'static str {
match level {
NotificationLevel::Info => "info",
NotificationLevel::Warning => "warning",
NotificationLevel::Error => "error",
NotificationLevel::Critical => "critical",
}
}
fn format_notification_text(notif: &Notification) -> String {
let utc = Utc::now();
let shanghai = FixedOffset::east_opt(8 * 3600)
.expect("valid Shanghai offset")
.from_utc_datetime(&utc.naive_utc());
format!(
"[BoosAPI][{}] {}\n\n{}\n\nLevel: {}\nLocal Time: {} (Asia/Shanghai)\nUTC Time: {}",
level_name(&notif.level).to_ascii_uppercase(),
notif.title,
notif.message,
level_name(&notif.level),
shanghai.format("%Y-%m-%d %H:%M:%S"),
utc.to_rfc3339_opts(SecondsFormat::Millis, true)
)
}
fn truncate_chars(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
value.chars().take(max_chars).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
async fn pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query("CREATE TABLE settings (key TEXT PRIMARY KEY NOT NULL, value TEXT)")
.execute(&pool)
.await
.unwrap();
pool
}
async fn set(pool: &SqlitePool, key: &str, value: &str) {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(key)
.bind(value)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn skips_when_no_channel_is_configured() {
let pool = pool().await;
let service = NotificationService::new(Arc::new(pool));
service
.send(Notification {
title: "Disk".to_string(),
message: "Almost full".to_string(),
level: NotificationLevel::Warning,
channel: Some("webhook".to_string()),
signature: None,
})
.await
.unwrap();
}
#[tokio::test]
async fn throttles_duplicate_signature() {
let pool = pool().await;
set(&pool, "notify_cooldown_sec", "60").await;
let service = NotificationService::new(Arc::new(pool));
let notification = Notification {
title: "Route down".to_string(),
message: "primary failed".to_string(),
level: NotificationLevel::Error,
channel: Some("webhook".to_string()),
signature: Some("route:1".to_string()),
};
service.send(notification.clone()).await.unwrap();
service.send(notification).await.unwrap();
let count: i64 = sqlx::query_scalar(
"SELECT count FROM notification_throttle WHERE signature = 'route:1'",
)
.fetch_one(&*service.db)
.await
.unwrap();
assert_eq!(count, 2);
}
#[test]
fn formats_telegram_compatible_text() {
let text = format_notification_text(&Notification {
title: "Title".to_string(),
message: "Message".to_string(),
level: NotificationLevel::Critical,
channel: None,
signature: None,
});
assert!(text.starts_with("[BoosAPI][CRITICAL] Title\n\nMessage"));
assert!(text.contains("Local Time:"));
assert!(text.contains("UTC Time:"));
}
#[test]
fn truncates_by_char_count() {
assert_eq!(truncate_chars("abcdef", 3), "abc");
assert_eq!(truncate_chars("短消息", 2), "短消");
}
}