Fixes and improvements

This commit is contained in:
imengying committed 2026-08-11 22:50:34 +08:00
1 parent 1c9540c13c
commit f24efd1401
33 files changed
+2618 -1093

No files matched your search

+110 -18
View File
@@ -1,12 +1,18 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hmac::{Hmac, Mac};
use hmac::{Hmac, KeyInit, Mac};
use pbkdf2::pbkdf2_hmac;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use worker::{Date, Env, Request};
use worker::{
js_sys::{Function, Reflect, Uint8Array},
wasm_bindgen::{JsCast, JsValue},
Date, Env, Request,
};
const SESSION_SECONDS: i64 = 7 * 24 * 60 * 60;
const TURNSTILE_PROOF_SECONDS: i64 = 60 * 60;
const PASSWORD_ROUNDS: usize = 10_000;
const THEME_PREVIEW_SECONDS: i64 = 10 * 60;
const SERVER_PASSWORD_ROUNDS: u32 = 10_000;
const JWT_HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
type HmacSha256 = Hmac<Sha256>;
@@ -47,36 +53,63 @@ pub fn admin_username(env: &Env, configured: &str) -> String {
.unwrap_or_else(|_| "admin".to_string())
}
fn password_digest(password: &str, salt: &str) -> String {
let mut digest = Sha256::digest(format!("nodeflare:{salt}:{password}").as_bytes()).to_vec();
for _ in 1..PASSWORD_ROUNDS {
let mut hasher = Sha256::new();
hasher.update(&digest);
hasher.update(salt.as_bytes());
digest = hasher.finalize().to_vec();
}
fn password_digest(password_derived: &str, salt: &str, rounds: u32) -> String {
let mut digest = [0_u8; 32];
pbkdf2_hmac::<Sha256>(
password_derived.as_bytes(),
salt.as_bytes(),
rounds,
&mut digest,
);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub fn hash_password(password: &str, salt: &str) -> String {
format!("sha256${salt}${}", password_digest(password, salt))
pub fn hash_password(password_derived: &str, salt: &str) -> String {
format!(
"pbkdf2_sha256_client_v1${SERVER_PASSWORD_ROUNDS}${salt}${}",
password_digest(password_derived, salt, SERVER_PASSWORD_ROUNDS)
)
}
fn verify_password_hash(password: &str, encoded: &str) -> bool {
pub fn random_salt() -> Option<String> {
let global = worker::js_sys::global();
let crypto = Reflect::get(&global, &JsValue::from_str("crypto")).ok()?;
let get_random_values = Reflect::get(&crypto, &JsValue::from_str("getRandomValues"))
.ok()?
.dyn_into::<Function>()
.ok()?;
let salt = Uint8Array::new_with_length(16);
get_random_values.call1(&crypto, &salt).ok()?;
Some(
salt.to_vec()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect(),
)
}
fn verify_password_hash(password_derived: &str, encoded: &str) -> bool {
let mut parts = encoded.split('$');
let Some(algorithm) = parts.next() else {
return false;
};
let Some(rounds) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
return false;
};
let Some(salt) = parts.next() else {
return false;
};
let Some(expected) = parts.next() else {
return false;
};
if algorithm != "sha256" || salt.is_empty() || parts.next().is_some() {
if algorithm != "pbkdf2_sha256_client_v1"
|| rounds != SERVER_PASSWORD_ROUNDS
|| salt.len() < 32
|| parts.next().is_some()
{
return false;
}
secure_eq(&password_digest(password, salt), expected)
secure_eq(&password_digest(password_derived, salt, rounds), expected)
}
pub fn verify_credentials(
@@ -85,6 +118,7 @@ pub fn verify_credentials(
password_hash: &str,
username: &str,
password: &str,
password_derived: &str,
) -> bool {
let expected_username = admin_username(env, configured_username);
let username_valid = secure_eq(&expected_username, username.trim());
@@ -93,7 +127,7 @@ pub fn verify_credentials(
.map(|expected| secure_eq(&expected, password))
.unwrap_or(false)
} else {
verify_password_hash(password, password_hash)
verify_password_hash(password_derived, password_hash)
};
username_valid && password_valid
}
@@ -198,6 +232,43 @@ pub fn verify_turnstile_proof(value: &str, env: &Env, password_hash: &str) -> bo
secure_eq(&expected, signature)
}
pub fn create_theme_preview_proof(
env: &Env,
password_hash: &str,
theme_id: &str,
) -> Option<String> {
let secret = signing_secret(env, password_hash)?;
let expires = Date::now().as_millis() as i64 / 1000 + THEME_PREVIEW_SECONDS;
let payload = format!("{theme_id}.{expires}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(format!("nodeflare-theme-preview:{payload}").as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
Some(format!("{payload}.{signature}"))
}
pub fn verify_theme_preview_proof(value: &str, env: &Env, password_hash: &str) -> Option<String> {
let secret = signing_secret(env, password_hash)?;
let mut parts = value.split('.');
let (Some(theme_id), Some(expires), Some(signature), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return None;
};
if theme_id.is_empty() || theme_id.len() > 80 || theme_id.contains('/') {
return None;
}
let expires = expires.parse::<i64>().ok()?;
let current = Date::now().as_millis() as i64 / 1000;
if expires < current || expires > current + THEME_PREVIEW_SECONDS + 60 {
return None;
}
let signature = URL_SAFE_NO_PAD.decode(signature).ok()?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(format!("nodeflare-theme-preview:{theme_id}.{expires}").as_bytes());
mac.verify_slice(&signature).ok()?;
Some(theme_id.to_string())
}
pub fn bearer_token(req: &Request) -> Option<String> {
req.headers()
.get("Authorization")
@@ -208,7 +279,28 @@ pub fn bearer_token(req: &Request) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{create_admin_jwt_with_secret, verify_admin_jwt, SESSION_SECONDS};
use super::{
create_admin_jwt_with_secret, hash_password, verify_admin_jwt, verify_password_hash,
SESSION_SECONDS,
};
#[test]
fn hashes_passwords_with_pbkdf2() {
let encoded = hash_password(
"correct horse battery staple",
"0123456789abcdef0123456789abcdef",
);
assert!(encoded.starts_with("pbkdf2_sha256_client_v1$10000$"));
assert!(verify_password_hash(
"correct horse battery staple",
&encoded
));
assert!(!verify_password_hash("wrong", &encoded));
assert!(!verify_password_hash(
"correct horse battery staple",
"sha256$0123456789abcdef0123456789abcdef$invalid"
));
}
#[test]
fn signs_and_verifies_admin_jwt() {
+262 -39
View File
@@ -1,28 +1,28 @@
use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Serialize, Serializer};
use worker::{wasm_bindgen::JsValue, D1Database, Date, Result};
use crate::models::{
AgentConfigView, AgentReport, AlertRuleInput, AlertRuleView, HistoryPoint, ServerInput,
ServerView, SettingsInput, TokenHashRow,
ServerView, SettingsInput, ThemeInput, ThemeView, TokenHashRow,
};
const SERVER_SELECT: &str = r#"
SELECT
s.id, s.name, s.region, s.group_name, s.tags, s.note, s.hidden,
s.sort_order, s.expires_at, s.traffic_limit, s.traffic_limit_type,
s.expires_at, s.traffic_limit, s.traffic_limit_type,
s.price, s.billing_cycle, s.currency, s.auto_renewal, s.public_remark,
s.network_interface, s.reset_day, s.report_interval, s.collect_interval,
s.rx_correction, s.tx_correction, s.offline_notify_disabled, s.auto_update,
s.created_at, s.updated_at,
s.created_at,
m.timestamp, m.cpu, m.load1, m.load5, m.load15, m.mem_used, m.mem_total,
m.swap_used, m.swap_total, m.disk_used, m.disk_total, m.net_in, m.net_out,
CASE WHEN m.net_rx_total IS NULL THEN NULL ELSE m.net_rx_total + s.rx_correction END AS net_rx_total,
CASE WHEN m.net_tx_total IS NULL THEN NULL ELSE m.net_tx_total + s.tx_correction END AS net_tx_total,
m.uptime, m.processes, m.tcp_connections,
m.udp_connections, m.cpu_cores, m.cpu_model, m.os, m.kernel, m.arch,
m.virtualization, m.ipv4, m.ipv6, m.gpu_usage, m.gpu_model, m.agent_version,
m.virtualization, m.gpu_usage, m.gpu_model, m.agent_version,
m.disk_read_bps, m.disk_write_bps, m.disk_read_iops, m.disk_write_iops,
m.disk_await_ms, m.disk_utilization, m.disk_info, m.gpu_info,
m.message
@@ -30,12 +30,45 @@ FROM servers s
LEFT JOIN latest_metrics m ON m.server_id = s.id
"#;
pub const SECRET_MASK: &str = "********";
fn secret_for_api(value: &str) -> &str {
if value.is_empty() {
""
} else {
SECRET_MASK
}
}
fn serialize_secret<S>(value: &str, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(secret_for_api(value))
}
#[derive(Debug, serde::Deserialize)]
struct SettingRow {
key: String,
value: String,
}
#[derive(Debug, Deserialize)]
struct ThemeRow {
id: String,
name: String,
description: String,
url: String,
}
#[derive(Debug, Deserialize)]
struct AgentConfigRow {
report_interval: i64,
collect_interval: i64,
network_interface: String,
auto_update: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SettingsView {
pub site_name: String,
@@ -46,7 +79,10 @@ pub struct SettingsView {
pub public_dashboard: bool,
pub offline_threshold_seconds: i64,
pub history_retention_days: i64,
#[serde(skip_serializing)]
pub history_cache_version: i64,
pub default_theme: String,
pub active_theme_id: String,
pub background_url: String,
pub theme_options: serde_json::Value,
pub show_search: bool,
@@ -63,16 +99,21 @@ pub struct SettingsView {
pub admin_password_configured: bool,
#[serde(skip_serializing)]
pub admin_password_hash: String,
#[serde(skip_serializing)]
pub password_client_salt: String,
pub turnstile_enabled: bool,
pub turnstile_login_enabled: bool,
pub turnstile_site_key: String,
#[serde(serialize_with = "serialize_secret")]
pub turnstile_secret_key: String,
pub notification_enabled: bool,
#[serde(serialize_with = "serialize_secret")]
pub notification_endpoint: String,
pub notification_target: String,
pub offline_alert_minutes: i64,
pub expiry_alert_days: i64,
pub cloudflare_account_id: String,
#[serde(serialize_with = "serialize_secret")]
pub cloudflare_api_token: String,
}
@@ -104,8 +145,6 @@ struct AlertRuleRow {
duration_minutes: i64,
aggregation: String,
enabled: i64,
created_at: i64,
updated_at: i64,
}
#[derive(Debug, Deserialize)]
@@ -118,6 +157,10 @@ pub struct AlertMetricRow {
pub server_id: String,
pub name: String,
pub value: f64,
pub sample_count: i64,
pub first_timestamp: i64,
pub last_timestamp: i64,
pub report_interval: i64,
}
#[derive(Debug, Deserialize)]
@@ -170,7 +213,7 @@ pub async fn get_token_hash(db: &D1Database, id: &str) -> Result<Option<TokenHas
}
pub async fn agent_config(db: &D1Database, id: &str) -> Result<Option<AgentConfigView>> {
let mut config: Option<AgentConfigView> = db
let row: Option<AgentConfigRow> = db
.prepare(
"SELECT report_interval, collect_interval, network_interface, auto_update \
FROM servers WHERE id = ?1",
@@ -178,11 +221,16 @@ pub async fn agent_config(db: &D1Database, id: &str) -> Result<Option<AgentConfi
.bind(&[text(id)])?
.first(None)
.await?;
if let Some(config) = config.as_mut() {
config.network_interface = config.network_interface.trim().to_string();
config.latency_tasks = crate::latency::tasks_for_server(db, id).await?;
}
Ok(config)
let Some(row) = row else {
return Ok(None);
};
Ok(Some(AgentConfigView {
report_interval: row.report_interval,
collect_interval: row.collect_interval,
network_interface: row.network_interface.trim().to_string(),
auto_update: row.auto_update,
latency_tasks: crate::latency::tasks_for_server(db, id).await?,
}))
}
pub async fn create_server(
@@ -329,8 +377,8 @@ pub async fn reorder_servers(db: &D1Database, ids: &[String]) -> Result<()> {
pub async fn list_alert_rules(db: &D1Database) -> Result<Vec<AlertRuleView>> {
let rows: Vec<AlertRuleRow> = db
.prepare(
"SELECT id, name, metric, threshold, duration_minutes, aggregation, enabled, \
created_at, updated_at FROM alert_rules ORDER BY created_at ASC",
"SELECT id, name, metric, threshold, duration_minutes, aggregation, enabled \
FROM alert_rules ORDER BY created_at ASC",
)
.all()
.await?
@@ -355,8 +403,6 @@ pub async fn list_alert_rules(db: &D1Database) -> Result<Vec<AlertRuleView>> {
aggregation: row.aggregation,
enabled: row.enabled,
server_ids: servers.into_iter().map(|row| row.server_id).collect(),
created_at: row.created_at,
updated_at: row.updated_at,
});
}
Ok(rules)
@@ -453,20 +499,41 @@ pub async fn alert_metric_values(
"AVG"
};
let since = current_time - rule.duration_minutes.clamp(1, 1440) * 60;
let minimum_samples = rule.duration_minutes.clamp(1, 1440);
let query = format!(
"SELECT s.id AS server_id, s.name, {aggregate}({metric}) AS value \
"SELECT s.id AS server_id, s.name, {aggregate}({metric}) AS value, \
COUNT(*) AS sample_count, MIN(h.timestamp) AS first_timestamp, \
MAX(h.timestamp) AS last_timestamp, s.report_interval \
FROM servers s JOIN metric_history h ON h.server_id=s.id \
WHERE h.timestamp >= ?1 AND s.hidden=0 AND (\
NOT EXISTS(SELECT 1 FROM alert_rule_servers ars WHERE ars.rule_id=?2) OR \
EXISTS(SELECT 1 FROM alert_rule_servers ars WHERE ars.rule_id=?2 AND ars.server_id=s.id)\
) GROUP BY s.id, s.name HAVING COUNT(*) >= ?3"
) GROUP BY s.id, s.name, s.report_interval"
);
db.prepare(query)
.bind(&[number(since), text(&rule.id), number(minimum_samples)])?
let rows: Vec<AlertMetricRow> = db
.prepare(query)
.bind(&[number(since), text(&rule.id)])?
.all()
.await?
.results()
.results()?;
Ok(rows
.into_iter()
.filter(|row| alert_window_covered(row, rule.duration_minutes, current_time))
.collect())
}
fn alert_window_covered(row: &AlertMetricRow, duration_minutes: i64, current_time: i64) -> bool {
let window_seconds = duration_minutes.clamp(1, 1440) * 60;
let report_interval = row.report_interval.clamp(15, 3600).max(60);
let expected_samples = ((window_seconds + report_interval - 1) / report_interval).max(1);
let required_samples = if expected_samples == 1 {
1
} else {
((expected_samples * 3 + 4) / 5).max(2)
};
let fresh_within = (report_interval * 2).max(120);
row.sample_count >= required_samples
&& row.first_timestamp <= row.last_timestamp
&& current_time.saturating_sub(row.last_timestamp) <= fresh_within
}
pub async fn active_alert_states(db: &D1Database) -> Result<std::collections::HashSet<String>> {
@@ -481,16 +548,25 @@ pub async fn active_alert_states(db: &D1Database) -> Result<std::collections::Ha
.collect())
}
pub async fn replace_active_alert_states(
pub async fn sync_active_alert_states(
db: &D1Database,
keys: &std::collections::HashSet<String>,
previous: &std::collections::HashSet<String>,
current: &std::collections::HashSet<String>,
) -> Result<()> {
let mut statements = vec![db.prepare("DELETE FROM alert_states")];
let mut statements = Vec::new();
let delete = db.prepare("DELETE FROM alert_states WHERE rule_id=?1 AND server_id=?2");
let insert = db.prepare(
"INSERT INTO alert_states(rule_id, server_id, active, updated_at) VALUES (?1, ?2, 1, ?3)",
"INSERT INTO alert_states(rule_id, server_id, active, updated_at) VALUES (?1, ?2, 1, ?3) \
ON CONFLICT(rule_id, server_id) DO UPDATE SET active=1, updated_at=excluded.updated_at",
);
let timestamp = now();
for key in keys {
for key in previous.difference(current) {
let Some((rule_id, server_id)) = key.split_once(':') else {
continue;
};
statements.push(delete.clone().bind(&[text(rule_id), text(server_id)])?);
}
for key in current.difference(previous) {
let Some((rule_id, server_id)) = key.split_once(':') else {
continue;
};
@@ -500,7 +576,9 @@ pub async fn replace_active_alert_states(
number(timestamp),
])?);
}
db.batch(statements).await?;
if !statements.is_empty() {
db.batch(statements).await?;
}
Ok(())
}
@@ -568,14 +646,13 @@ pub async fn save_reports(db: &D1Database, server_id: &str, reports: &[AgentRepo
swap_used, swap_total, disk_used, disk_total, net_in, net_out,
net_rx_total, net_tx_total, uptime, processes, tcp_connections,
udp_connections, cpu_cores, cpu_model, os, kernel, arch,
virtualization, ipv4, ipv6, gpu_usage, gpu_model, agent_version,
virtualization, gpu_usage, gpu_model, agent_version,
disk_read_bps, disk_write_bps, disk_read_iops, disk_write_iops,
disk_await_ms, disk_utilization, disk_info, gpu_info, message
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26,
?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38,
?39, ?40
?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38
) ON CONFLICT(server_id) DO UPDATE SET
timestamp=excluded.timestamp, cpu=excluded.cpu, load1=excluded.load1,
load5=excluded.load5, load15=excluded.load15, mem_used=excluded.mem_used,
@@ -587,8 +664,7 @@ pub async fn save_reports(db: &D1Database, server_id: &str, reports: &[AgentRepo
tcp_connections=excluded.tcp_connections, udp_connections=excluded.udp_connections,
cpu_cores=excluded.cpu_cores, cpu_model=excluded.cpu_model, os=excluded.os,
kernel=excluded.kernel, arch=excluded.arch,
virtualization=excluded.virtualization, ipv4=excluded.ipv4, ipv6=excluded.ipv6,
gpu_usage=excluded.gpu_usage, gpu_model=excluded.gpu_model,
virtualization=excluded.virtualization, gpu_usage=excluded.gpu_usage, gpu_model=excluded.gpu_model,
agent_version=excluded.agent_version,
disk_read_bps=excluded.disk_read_bps, disk_write_bps=excluded.disk_write_bps,
disk_read_iops=excluded.disk_read_iops, disk_write_iops=excluded.disk_write_iops,
@@ -676,8 +752,6 @@ fn report_values(server_id: &str, report: &AgentReport, timestamp: i64) -> Vec<J
text(&report.kernel),
text(&report.arch),
text(&report.virtualization),
text(&report.ipv4),
text(&report.ipv6),
number(report.gpu_usage),
text(&report.gpu_model),
text(&report.agent_version),
@@ -778,7 +852,9 @@ pub async fn settings(
"history_retention_days",
default_retention.clamp(1, 365),
),
history_cache_version: integer_setting(&values, "history_cache_version", 0),
default_theme: string_setting(&values, "default_theme", "system"),
active_theme_id: string_setting(&values, "active_theme_id", crate::theme::BUILTIN_THEME_ID),
background_url: string_setting(&values, "background_url", ""),
theme_options: values
.get("theme_options")
@@ -800,6 +876,7 @@ pub async fn settings(
.get("admin_password_hash")
.is_some_and(|value| !value.is_empty()),
admin_password_hash: string_setting(&values, "admin_password_hash", ""),
password_client_salt: string_setting(&values, "password_client_salt", ""),
turnstile_enabled: bool_setting(&values, "turnstile_enabled", false),
turnstile_login_enabled: bool_setting(&values, "turnstile_login_enabled", true),
turnstile_site_key: string_setting(&values, "turnstile_site_key", ""),
@@ -863,6 +940,9 @@ pub async fn update_settings(
if let Some(value) = input.default_theme.as_deref() {
push_setting!("default_theme", value);
}
if let Some(value) = input.active_theme_id.as_deref() {
push_setting!("active_theme_id", value.trim());
}
if let Some(value) = input.background_url.as_deref() {
push_setting!("background_url", value.trim());
}
@@ -918,13 +998,21 @@ pub async fn update_settings(
if let Some(value) = input.turnstile_site_key.as_deref() {
push_setting!("turnstile_site_key", value.trim());
}
if let Some(value) = input.turnstile_secret_key.as_deref() {
if let Some(value) = input
.turnstile_secret_key
.as_deref()
.filter(|value| value.trim() != SECRET_MASK)
{
push_setting!("turnstile_secret_key", value.trim());
}
if let Some(value) = input.notification_enabled {
push_setting!("notification_enabled", if value { "true" } else { "false" });
}
if let Some(value) = input.notification_endpoint.as_deref() {
if let Some(value) = input
.notification_endpoint
.as_deref()
.filter(|value| value.trim() != SECRET_MASK)
{
push_setting!("notification_endpoint", value.trim());
}
if let Some(value) = input.notification_target.as_deref() {
@@ -946,13 +1034,104 @@ pub async fn update_settings(
};
}
push_trimmed!(input.cloudflare_account_id, "cloudflare_account_id");
push_trimmed!(input.cloudflare_api_token, "cloudflare_api_token");
if let Some(value) = input
.cloudflare_api_token
.as_deref()
.filter(|value| value.trim() != SECRET_MASK)
{
push_setting!("cloudflare_api_token", value.trim());
}
if !statements.is_empty() {
db.batch(statements).await?;
}
Ok(())
}
pub async fn list_themes(db: &D1Database, active_id: &str) -> Result<Vec<ThemeView>> {
let rows: Vec<ThemeRow> = db
.prepare("SELECT id, name, description, url FROM themes ORDER BY created_at DESC")
.all()
.await?
.results()?;
Ok(rows
.into_iter()
.map(|row| {
let active = row.id == active_id;
ThemeView {
id: row.id,
name: row.name,
description: row.description,
url: row.url,
builtin: false,
active,
}
})
.collect())
}
pub async fn create_theme(
db: &D1Database,
id: &str,
input: &ThemeInput,
source_url: &str,
timestamp: i64,
) -> Result<()> {
db.prepare(
"INSERT INTO themes(id, name, description, url, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(&[
text(id),
text(input.name.trim()),
text(input.description.trim()),
text(source_url),
number(timestamp),
])?
.run()
.await?;
Ok(())
}
pub async fn theme_exists(db: &D1Database, id: &str) -> Result<bool> {
let row: Option<ThemeRow> = db
.prepare("SELECT id, name, description, url FROM themes WHERE id = ?1")
.bind(&[text(id)])?
.first(None)
.await?;
Ok(row.is_some())
}
pub async fn theme_url(db: &D1Database, id: &str) -> Result<Option<String>> {
let row: Option<ThemeRow> = db
.prepare("SELECT id, name, description, url FROM themes WHERE id = ?1")
.bind(&[text(id)])?
.first(None)
.await?;
row.map(|theme| crate::theme::resolve_url(&theme.url).map(|resolved| resolved.resolved_url))
.transpose()
}
pub async fn set_active_theme(db: &D1Database, id: &str) -> Result<bool> {
if id != crate::theme::BUILTIN_THEME_ID && !theme_exists(db, id).await? {
return Ok(false);
}
save_setting(db, "active_theme_id", id).await?;
Ok(true)
}
pub async fn delete_theme(db: &D1Database, id: &str) -> Result<bool> {
let result = db
.prepare("DELETE FROM themes WHERE id = ?1")
.bind(&[text(id)])?
.run()
.await?;
let deleted = result.meta()?.and_then(|meta| meta.changes).unwrap_or(0) > 0;
if deleted && get_setting(db, "active_theme_id").await?.as_deref() == Some(id) {
save_setting(db, "active_theme_id", crate::theme::BUILTIN_THEME_ID).await?;
}
Ok(deleted)
}
pub async fn cleanup_history(db: &D1Database, retention_days: i64) -> Result<()> {
let cutoff = now() - retention_days.clamp(1, 365) * 86_400;
let recent_cutoff = now() - 7 * 86_400;
@@ -1120,3 +1299,47 @@ pub async fn update_expiry(db: &D1Database, id: &str, expires_at: i64) -> Result
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{alert_window_covered, secret_for_api, AlertMetricRow, SECRET_MASK};
fn row(samples: i64, last_timestamp: i64, report_interval: i64) -> AlertMetricRow {
AlertMetricRow {
server_id: "server".to_string(),
name: "Server".to_string(),
value: 90.0,
sample_count: samples,
first_timestamp: last_timestamp - samples.saturating_sub(1) * 60,
last_timestamp,
report_interval,
}
}
#[test]
fn alert_coverage_tracks_report_interval_and_freshness() {
let current = 10_000;
assert!(alert_window_covered(&row(6, current - 30, 60), 10, current));
assert!(!alert_window_covered(
&row(5, current - 30, 60),
10,
current
));
assert!(alert_window_covered(
&row(2, current - 60, 300),
10,
current
));
assert!(!alert_window_covered(
&row(2, current - 700, 300),
10,
current
));
}
#[test]
fn masks_stored_secrets_in_api_responses() {
assert_eq!(secret_for_api(""), "");
assert_eq!(secret_for_api("stored-secret"), SECRET_MASK);
}
}
+4 -13
View File
@@ -13,9 +13,6 @@ struct TaskRow {
target: String,
interval_seconds: i64,
default_enabled: i64,
sort_order: i64,
created_at: i64,
updated_at: i64,
}
#[derive(Debug, Deserialize)]
@@ -32,10 +29,7 @@ pub struct LatencyTaskView {
pub target: String,
pub interval_seconds: i64,
pub default_enabled: bool,
pub sort_order: i64,
pub server_ids: Vec<String>,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -61,8 +55,8 @@ fn number(value: impl ToString) -> JsValue {
pub async fn list_tasks(db: &D1Database) -> Result<Vec<LatencyTaskView>> {
let rows: Vec<TaskRow> = db
.prepare(
"SELECT id, name, task_type, target, interval_seconds, default_enabled, \
sort_order, created_at, updated_at FROM latency_tasks \
"SELECT id, name, task_type, target, interval_seconds, default_enabled \
FROM latency_tasks \
ORDER BY sort_order ASC, created_at ASC",
)
.all()
@@ -93,9 +87,6 @@ pub async fn list_tasks(db: &D1Database) -> Result<Vec<LatencyTaskView>> {
target: row.target,
interval_seconds: row.interval_seconds,
default_enabled: row.default_enabled != 0,
sort_order: row.sort_order,
created_at: row.created_at,
updated_at: row.updated_at,
})
.collect())
}
@@ -153,8 +144,8 @@ pub async fn update_task(
) -> Result<bool> {
let exists: Option<TaskRow> = db
.prepare(
"SELECT id, name, task_type, target, interval_seconds, default_enabled, \
sort_order, created_at, updated_at FROM latency_tasks WHERE id = ?1",
"SELECT id, name, task_type, target, interval_seconds, default_enabled \
FROM latency_tasks WHERE id = ?1",
)
.bind(&[text(id)])?
.first(None)
+945 -194
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -7,6 +7,9 @@ pub struct LiveHub {
impl DurableObject for LiveHub {
fn new(state: State, _env: Env) -> Self {
if let Ok(pair) = WebSocketRequestResponsePair::new("ping", "pong") {
state.set_websocket_auto_response(&pair);
}
Self { state }
}
@@ -51,12 +54,9 @@ impl DurableObject for LiveHub {
async fn websocket_message(
&self,
ws: WebSocket,
message: WebSocketIncomingMessage,
_ws: WebSocket,
_message: WebSocketIncomingMessage,
) -> Result<()> {
if matches!(message, WebSocketIncomingMessage::String(ref value) if value == "ping") {
ws.send_with_str("pong")?;
}
Ok(())
}
+45 -120
View File
@@ -1,102 +1,49 @@
use serde::{Deserialize, Serialize};
fn default_group() -> String {
"默认".to_string()
}
fn default_price() -> f64 {
0.0
}
fn default_enabled() -> bool {
true
}
fn default_billing_cycle() -> i64 {
30
}
fn default_currency() -> String {
"CNY".to_string()
}
fn default_traffic_limit_type() -> String {
"sum".to_string()
}
fn default_reset_day() -> i64 {
1
}
fn default_report_interval() -> i64 {
60
}
fn default_collect_interval() -> i64 {
5
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LoginRequest {
pub username: String,
pub password: String,
#[serde(default)]
pub password_derived: String,
pub turnstile_token: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TurnstileVerifyRequest {
pub token: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerInput {
pub name: String,
#[serde(default)]
pub region: String,
#[serde(default = "default_group")]
pub group_name: String,
#[serde(default)]
pub tags: String,
#[serde(default)]
pub note: String,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub expires_at: Option<i64>,
#[serde(default)]
pub traffic_limit: i64,
#[serde(default = "default_traffic_limit_type")]
pub traffic_limit_type: String,
#[serde(default = "default_price")]
pub price: f64,
#[serde(default = "default_billing_cycle")]
pub billing_cycle: i64,
#[serde(default = "default_currency")]
pub currency: String,
#[serde(default)]
pub auto_renewal: bool,
#[serde(default)]
pub public_remark: String,
#[serde(default)]
pub network_interface: String,
#[serde(default = "default_reset_day")]
pub reset_day: i64,
#[serde(default = "default_report_interval")]
pub report_interval: i64,
#[serde(default = "default_collect_interval")]
pub collect_interval: i64,
#[serde(default)]
pub rx_correction: i64,
#[serde(default)]
pub tx_correction: i64,
#[serde(default)]
pub offline_notify_disabled: bool,
#[serde(default = "default_enabled")]
pub auto_update: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SettingsInput {
pub site_name: Option<String>,
pub site_description: Option<String>,
@@ -107,6 +54,7 @@ pub struct SettingsInput {
pub offline_threshold_seconds: Option<i64>,
pub history_retention_days: Option<i64>,
pub default_theme: Option<String>,
pub active_theme_id: Option<String>,
pub background_url: Option<String>,
pub theme_options: Option<serde_json::Value>,
pub show_search: Option<bool>,
@@ -121,6 +69,7 @@ pub struct SettingsInput {
pub show_uptime: Option<bool>,
pub admin_username: Option<String>,
pub new_password: Option<String>,
pub new_password_derived: Option<String>,
pub turnstile_enabled: Option<bool>,
pub turnstile_login_enabled: Option<bool>,
pub turnstile_site_key: Option<String>,
@@ -135,24 +84,43 @@ pub struct SettingsInput {
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ThemeInput {
pub name: String,
pub description: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ThemeView {
pub id: String,
pub name: String,
pub description: String,
pub url: String,
pub builtin: bool,
pub active: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerOrderInput {
pub ids: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerBatchInput {
pub ids: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LatencyTaskInput {
pub name: String,
pub task_type: String,
pub target: String,
pub interval_seconds: i64,
#[serde(default)]
pub default_enabled: bool,
#[serde(default)]
pub server_ids: Vec<String>,
}
@@ -166,6 +134,7 @@ pub struct AgentLatencyTask {
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentLatencyResult {
pub task_id: String,
pub timestamp: i64,
@@ -173,7 +142,8 @@ pub struct AgentLatencyResult {
pub packet_loss: f64,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentDiskMetric {
pub name: String,
pub mount_point: String,
@@ -187,7 +157,8 @@ pub struct AgentDiskMetric {
pub utilization: f64,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentGpuMetric {
pub model: String,
pub usage: f64,
@@ -195,93 +166,53 @@ pub struct AgentGpuMetric {
pub memory_total: i64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentReportBatch {
pub server_id: String,
pub samples: Vec<AgentReport>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentReport {
#[serde(default)]
pub timestamp: i64,
#[serde(default)]
pub cpu: f64,
#[serde(default)]
pub load1: f64,
#[serde(default)]
pub load5: f64,
#[serde(default)]
pub load15: f64,
#[serde(default)]
pub mem_used: i64,
#[serde(default)]
pub mem_total: i64,
#[serde(default)]
pub swap_used: i64,
#[serde(default)]
pub swap_total: i64,
#[serde(default)]
pub disk_used: i64,
#[serde(default)]
pub disk_total: i64,
#[serde(default)]
pub net_in: f64,
#[serde(default)]
pub net_out: f64,
#[serde(default)]
pub net_rx_total: i64,
#[serde(default)]
pub net_tx_total: i64,
#[serde(default)]
pub uptime: i64,
#[serde(default)]
pub processes: i64,
#[serde(default)]
pub tcp_connections: i64,
#[serde(default)]
pub udp_connections: i64,
#[serde(default)]
pub cpu_cores: i64,
#[serde(default)]
pub cpu_model: String,
#[serde(default)]
pub os: String,
#[serde(default)]
pub kernel: String,
#[serde(default)]
pub arch: String,
#[serde(default)]
pub virtualization: String,
#[serde(default)]
pub ipv4: String,
#[serde(default)]
pub ipv6: String,
#[serde(default)]
pub gpu_usage: f64,
#[serde(default)]
pub gpu_model: String,
#[serde(default)]
pub agent_version: String,
#[serde(default)]
pub disk_read_bps: f64,
#[serde(default)]
pub disk_write_bps: f64,
#[serde(default)]
pub disk_read_iops: f64,
#[serde(default)]
pub disk_write_iops: f64,
#[serde(default)]
pub disk_await_ms: f64,
#[serde(default)]
pub disk_utilization: f64,
#[serde(default)]
pub disks: Vec<AgentDiskMetric>,
#[serde(default)]
pub gpus: Vec<AgentGpuMetric>,
#[serde(default)]
pub message: String,
#[serde(default)]
pub latency_results: Vec<AgentLatencyResult>,
}
@@ -294,7 +225,6 @@ pub struct ServerView {
pub tags: String,
pub note: String,
pub hidden: i64,
pub sort_order: i64,
pub expires_at: Option<i64>,
pub traffic_limit: i64,
pub traffic_limit_type: String,
@@ -311,8 +241,8 @@ pub struct ServerView {
pub tx_correction: i64,
pub offline_notify_disabled: i64,
pub auto_update: i64,
#[serde(skip_serializing)]
pub created_at: i64,
pub updated_at: i64,
pub timestamp: Option<i64>,
pub cpu: Option<f64>,
pub load1: Option<f64>,
@@ -338,8 +268,6 @@ pub struct ServerView {
pub kernel: Option<String>,
pub arch: Option<String>,
pub virtualization: Option<String>,
pub ipv4: Option<String>,
pub ipv6: Option<String>,
pub gpu_usage: Option<f64>,
pub gpu_model: Option<String>,
pub agent_version: Option<String>,
@@ -349,7 +277,9 @@ pub struct ServerView {
pub disk_write_iops: Option<f64>,
pub disk_await_ms: Option<f64>,
pub disk_utilization: Option<f64>,
#[serde(skip_serializing)]
pub disk_info: Option<String>,
#[serde(skip_serializing)]
pub gpu_info: Option<String>,
pub message: Option<String>,
}
@@ -389,30 +319,28 @@ pub struct TokenHashRow {
pub hidden: i64,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Serialize)]
pub struct AgentConfigView {
pub report_interval: i64,
pub collect_interval: i64,
pub network_interface: String,
pub auto_update: i64,
#[serde(default)]
pub latency_tasks: Vec<AgentLatencyTask>,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AlertRuleInput {
pub name: String,
pub metric: String,
pub threshold: f64,
pub duration_minutes: i64,
pub aggregation: String,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub server_ids: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Serialize)]
pub struct AlertRuleView {
pub id: String,
pub name: String,
@@ -421,10 +349,7 @@ pub struct AlertRuleView {
pub duration_minutes: i64,
pub aggregation: String,
pub enabled: i64,
#[serde(default)]
pub server_ids: Vec<String>,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Serialize)]
+29 -6
View File
@@ -5,7 +5,7 @@ use worker::{wasm_bindgen::JsValue, D1Database, Fetch, Method, Request, RequestI
use crate::db::{self, SettingsView};
#[derive(Default, Deserialize, Serialize)]
#[derive(Default, Deserialize, Serialize, PartialEq, Eq)]
struct AlertState {
offline: HashSet<String>,
expiry: HashSet<String>,
@@ -63,7 +63,7 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
let servers = db::list_servers(db_conn, true).await?;
let stored = db::get_setting(db_conn, "alert_state").await?;
let mut previous: AlertState = stored
let previous: AlertState = stored
.as_deref()
.and_then(|value| serde_json::from_str(value).ok())
.unwrap_or_default();
@@ -72,7 +72,7 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
let offline_after = settings.offline_alert_minutes.clamp(2, 1440) * 60;
let mut messages = Vec::new();
for server in servers {
for server in &servers {
let server_id = server.id.clone();
let is_offline = server
.timestamp
@@ -119,10 +119,26 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
let previous_resources = db::active_alert_states(db_conn).await?;
let mut current_resources = HashSet::new();
let mut evaluated_resources = HashSet::new();
let mut eligible_resources = HashSet::new();
for rule in db::list_alert_rules(db_conn).await? {
if rule.enabled == 0 {
continue;
}
if rule.server_ids.is_empty() {
eligible_resources.extend(
servers
.iter()
.filter(|server| server.hidden == 0)
.map(|server| format!("{}:{}", rule.id, server.id)),
);
} else {
eligible_resources.extend(
rule.server_ids
.iter()
.map(|server_id| format!("{}:{server_id}", rule.id)),
);
}
let metric_label = match rule.metric.as_str() {
"cpu" => "CPU",
"memory" => "内存",
@@ -138,6 +154,7 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
};
for value in db::alert_metric_values(db_conn, &rule, current_time).await? {
let key = format!("{}:{}", rule.id, value.server_id);
evaluated_resources.insert(key.clone());
if value.value >= rule.threshold {
current_resources.insert(key.clone());
if !previous_resources.contains(&key) {
@@ -163,12 +180,18 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
}
}
}
for key in &previous_resources {
if eligible_resources.contains(key) && !evaluated_resources.contains(key) {
current_resources.insert(key.clone());
}
}
if !messages.is_empty() {
let body = format!("NodeFlare 告警\n\n{}", messages.join("\n"));
send(settings, &body).await?;
}
previous = current;
db::save_setting(db_conn, "alert_state", &serde_json::to_string(&previous)?).await?;
db::replace_active_alert_states(db_conn, &current_resources).await
if previous != current {
db::save_setting(db_conn, "alert_state", &serde_json::to_string(&current)?).await?;
}
db::sync_active_alert_states(db_conn, &previous_resources, &current_resources).await
}
+218 -1
View File
@@ -1,4 +1,185 @@
use futures_util::TryStreamExt;
use serde_json::Value;
use worker::{Error, Fetch, Method, Request, Response, Result, Url};
pub const BUILTIN_THEME_ID: &str = "builtin-komari-glass";
pub const BUILTIN_THEME_NAME: &str = "Komari Glass";
pub const INDEX_MAX_BYTES: usize = 4 * 1024 * 1024;
pub const ASSET_MAX_BYTES: usize = 16 * 1024 * 1024;
pub struct ResolvedTheme {
pub source_url: String,
pub resolved_url: String,
}
pub fn normalize_url(value: &str) -> Option<String> {
let raw = value.trim();
if !(12..=2048).contains(&raw.len()) || raw.contains('\\') {
return None;
}
let parsed = Url::parse(raw).ok()?;
if parsed.scheme() != "https"
|| parsed.host_str() != Some("github.com")
|| parsed.port().is_some()
|| parsed.username() != ""
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return None;
}
let path = parsed.path().trim_matches('/');
let parts: Vec<_> = path.split('/').filter(|part| !part.is_empty()).collect();
if parts.len() < 4
|| parts[2] != "tree"
|| parts.iter().any(|part| {
part.is_empty()
|| !part
.chars()
.all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character))
|| matches!(*part, "." | "..")
})
{
return None;
}
Some(format!("https://github.com/{}", parts.join("/")))
}
pub fn resolve_url(value: &str) -> Result<ResolvedTheme> {
let source_url =
normalize_url(value).ok_or_else(|| Error::RustError("主题 URL 格式无效".to_string()))?;
let parsed = Url::parse(&source_url)?;
let parts: Vec<_> = parsed
.path()
.trim_matches('/')
.split('/')
.filter(|part| !part.is_empty())
.collect();
let owner = parts[0];
let repository = parts[1];
let reference = parts[3];
let subdirectory = parts[4..].join("/");
let suffix = if subdirectory.is_empty() {
String::new()
} else {
format!("/{subdirectory}")
};
Ok(ResolvedTheme {
source_url,
resolved_url: format!(
"https://raw.githubusercontent.com/{owner}/{repository}/{reference}{suffix}"
),
})
}
fn remote_url(base: &str, relative: &str) -> String {
format!("{}/{}", base.trim_end_matches('/'), relative)
}
fn raw_github_base(base: &str) -> Option<Url> {
let parsed = Url::parse(&format!("{}/", base.trim_end_matches('/'))).ok()?;
if parsed.scheme() != "https"
|| parsed.host_str() != Some("raw.githubusercontent.com")
|| parsed.port().is_some()
|| parsed.username() != ""
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return None;
}
let parts = parsed
.path()
.trim_matches('/')
.split('/')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
(parts.len() >= 3
&& parts.iter().all(|part| {
!matches!(*part, "." | "..")
&& part
.chars()
.all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character))
}))
.then_some(parsed)
}
pub fn asset_url(base: &str, path: &str) -> Option<String> {
let relative = path.strip_prefix("/assets/")?;
if relative.is_empty()
|| relative.len() > 1024
|| relative
.chars()
.any(|character| character.is_control() || matches!(character, '%' | '\\' | '?' | '#'))
|| relative
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
{
return None;
}
let base = raw_github_base(base)?;
let remote = base.join(&format!("assets/{relative}")).ok()?;
let expected_path = format!("{}assets/{relative}", base.path());
if remote.origin() != base.origin() || remote.path() != expected_path {
return None;
}
Some(remote.to_string())
}
pub fn index_url(base: &str) -> Option<String> {
raw_github_base(base).map(|base| remote_url(base.as_str(), "index.html"))
}
pub async fn read_response_limited(
response: &mut Response,
limit: usize,
) -> Result<Option<Vec<u8>>> {
let declared = response
.headers()
.get("Content-Length")?
.and_then(|value| value.parse::<usize>().ok());
if declared.is_some_and(|length| length == 0 || length > limit) {
return Ok(None);
}
let mut body = Vec::with_capacity(declared.unwrap_or(0).min(limit));
let mut stream = response.stream()?;
while let Some(mut chunk) = stream.try_next().await? {
let Some(length) = body.len().checked_add(chunk.len()) else {
return Ok(None);
};
if length > limit {
return Ok(None);
}
body.append(&mut chunk);
}
Ok((!body.is_empty()).then_some(body))
}
pub async fn validate_remote(base: &str) -> Result<()> {
let index = index_url(base)
.ok_or_else(|| Error::RustError("主题资源地址不是 GitHub Raw 地址".to_string()))?;
let request = Request::new(&index, Method::Get)?;
let mut response = Fetch::Request(request).send().await?;
let status = response.status_code();
if !(200..300).contains(&status) {
return Err(Error::RustError(format!(
"主题 index.html 返回 HTTP {status}"
)));
}
let body = read_response_limited(&mut response, INDEX_MAX_BYTES)
.await?
.ok_or_else(|| Error::RustError("主题 index.html 内容无效".to_string()))?;
let body = String::from_utf8(body)
.map_err(|_| Error::RustError("主题 index.html 不是 UTF-8 文本".to_string()))?;
if body.trim().is_empty() {
return Err(Error::RustError("主题 index.html 内容无效".to_string()));
}
Ok(())
}
pub fn settings_schema() -> Value {
let currencies = [
@@ -35,7 +216,7 @@ pub fn settings_schema() -> Value {
#[cfg(test)]
mod tests {
use super::settings_schema;
use super::{asset_url, normalize_url, resolve_url, settings_schema};
#[test]
fn exposes_builtin_glass_theme_settings() {
@@ -45,4 +226,40 @@ mod tests {
assert!(settings.iter().any(|field| field["key"] == "enableBlur"));
assert_eq!(schema["source"], "builtin");
}
#[test]
fn accepts_only_github_tree_urls() {
assert_eq!(
normalize_url("https://github.com/acme/theme/tree/abc123").as_deref(),
Some("https://github.com/acme/theme/tree/abc123")
);
assert!(normalize_url("https://themes.example.com/nodeflare").is_none());
assert!(normalize_url("http://themes.example.com/theme").is_none());
assert!(normalize_url("https://user@example.com/theme").is_none());
assert!(normalize_url("https://github.com/acme/theme").is_none());
assert_eq!(
resolve_url("https://github.com/acme/theme/tree/main/dist")
.expect("GitHub theme URL")
.resolved_url,
"https://raw.githubusercontent.com/acme/theme/main/dist"
);
}
#[test]
fn limits_proxy_paths_to_theme_assets() {
assert_eq!(
asset_url(
"https://raw.githubusercontent.com/acme/theme/main",
"/assets/app.js"
)
.as_deref(),
Some("https://raw.githubusercontent.com/acme/theme/main/assets/app.js")
);
assert!(asset_url("https://example.com/theme", "/assets/app.js").is_none());
assert!(asset_url("https://example.com/theme", "/logo.svg").is_none());
assert!(asset_url("https://example.com/theme", "/assets/../secret").is_none());
assert!(asset_url("https://example.com/theme", "/assets/%2e%2e/secret").is_none());
assert!(asset_url("https://example.com/theme", "/assets/..\\secret").is_none());
assert!(asset_url("https://example.com/theme", "/assets/a%2fb.js").is_none());
}
}