整体优化

This commit is contained in:
imengying committed 2026-08-18 21:10:44 +08:00
1 parent 467961eedf
commit e153d0ad10
30 files changed
+554 -317

No files matched your search

+4 -88
View File
@@ -2315,20 +2315,16 @@ fn main() {
#[cfg(test)]
mod tests {
use clap::Parser;
use std::net::TcpListener;
use std::thread;
use std::time::{Duration, Instant};
use super::{
ack_interval, advance_deadline, agent_artifact_name, clock_offset_from_http_date,
corrected_timestamp, executable_format_valid, gpu_name_from_uevent, is_public_probe_ip,
live_batch_interval, live_endpoint, live_update_payload, median, mirrored_download_url,
ack_interval, advance_deadline, clock_offset_from_http_date, corrected_timestamp,
gpu_name_from_uevent, is_public_probe_ip, live_endpoint, live_update_payload,
normalized_version, parse_lspci_gpu_names, parse_probe_target,
parse_system_profiler_gpu_names, ping_latency, prune_report_samples, release_asset_sha256,
report_retry_delay, sanitize_latency_tasks, selected_interface, tcp_latency_probe_address,
valid_endpoint, valid_sample_schedule, version_triplet, CliOptions, ClockCalibration,
sanitize_latency_tasks, valid_endpoint, version_triplet, CliOptions, ClockCalibration,
GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, Report, CLOCK_CALIBRATION_MAX_AGE,
MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, PROBE_ATTEMPTS,
MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS,
};
#[cfg(any(target_os = "windows", target_os = "macos"))]
@@ -2336,13 +2332,6 @@ mod tests {
#[cfg(target_os = "linux")]
use super::disk_device;
#[test]
fn selects_network_interfaces() {
assert!(selected_interface("eth0", ""));
assert!(!selected_interface("lo", ""));
assert!(selected_interface("ens3", "eth0, ens3"));
}
#[test]
fn parses_platform_gpu_names() {
let lspci = "00:02.0 VGA compatible controller: Intel Corporation CometLake-S GT2 [UHD Graphics 630] (rev 05)\n\
@@ -2421,17 +2410,6 @@ mod tests {
assert!(!valid_endpoint("https://bad host.example"));
}
#[test]
fn builds_mirrored_download_urls() {
let release =
"https://github.com/imengying/NodeFlare/releases/download/v1.2.3/agent-linux-x86_64";
assert_eq!(mirrored_download_url("", release), release);
assert_eq!(
mirrored_download_url("https://mirror.example.com/", release),
format!("https://mirror.example.com/{release}")
);
}
#[test]
fn builds_live_websocket_endpoints() {
assert_eq!(
@@ -2577,30 +2555,6 @@ mod tests {
assert!(!sanitized.iter().any(|task| task.id == "bad"));
}
#[test]
fn backs_off_failed_reports() {
assert_eq!(report_retry_delay(0, 60), Duration::from_secs(60));
assert_eq!(report_retry_delay(1, 60), Duration::from_secs(5));
assert_eq!(report_retry_delay(4, 60), Duration::from_secs(40));
assert_eq!(report_retry_delay(20, 60), Duration::from_secs(300));
}
#[test]
fn validates_sample_schedules() {
assert!(valid_sample_schedule(3600, 5));
assert!(!valid_sample_schedule(3600, 4));
assert!(!valid_sample_schedule(60, 0));
assert!(!valid_sample_schedule(10, 1));
}
#[test]
fn batches_live_samples_at_one_fifteenth_of_the_history_interval() {
assert_eq!(live_batch_interval(60, 1), Duration::from_secs(4));
assert_eq!(live_batch_interval(60, 5), Duration::from_secs(5));
assert_eq!(live_batch_interval(120, 1), Duration::from_secs(8));
assert_eq!(live_batch_interval(3_600, 1), Duration::from_secs(60));
}
#[test]
fn advances_collection_deadlines_without_drift() {
let start = Instant::now();
@@ -2661,23 +2615,6 @@ mod tests {
assert_eq!(connection_counts_from_netstat(output), (2, 1));
}
#[test]
fn selects_current_platform_artifact() {
#[cfg(target_os = "linux")]
assert!(agent_artifact_name().is_some_and(|name| name.starts_with("agent-linux-")));
#[cfg(target_os = "windows")]
assert_eq!(agent_artifact_name(), Some("agent-windows-x86_64.exe"));
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
assert_eq!(agent_artifact_name(), Some("agent-macos-aarch64"));
}
#[test]
fn recognizes_current_executable_format() {
assert!(executable_format_valid(
&std::env::current_exe().expect("current executable")
));
}
#[test]
fn parses_probe_targets() {
assert_eq!(
@@ -2729,27 +2666,6 @@ mod tests {
}
}
#[test]
fn calculates_median() {
assert_eq!(median(&mut [4.0, 1.0, 3.0]), 3.0);
assert_eq!(median(&mut [4.0, 1.0, 3.0, 2.0]), 2.5);
}
#[test]
fn measures_local_tcp_latency() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let address = listener.local_addr().expect("listener address");
let accepter = thread::spawn(move || {
for _ in 0..PROBE_ATTEMPTS {
listener.accept().expect("accept probe connection");
}
});
let (latency, loss) = tcp_latency_probe_address(&address);
accepter.join().expect("join listener");
assert!(latency >= 0.0);
assert_eq!(loss, 0.0);
}
#[test]
fn parses_ping_latency() {
assert_eq!(ping_latency("64 bytes time=12.34 ms"), Some(12.34));
+8 -7
View File
@@ -2,6 +2,7 @@ import { KeyRound, Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { api, ApiError, setToken } from "./api";
import { NodeCard } from "./components/NodeCard";
import { SiteLogo } from "./components/SiteLogo";
import { StatsBar } from "./components/StatsBar";
import { TurnstileWidget } from "./components/TurnstileWidget";
import { demoConfig, demoExchangeRates, demoServers } from "./demo";
@@ -279,8 +280,8 @@ export default function App() {
link.rel = "icon";
document.head.append(link);
}
link.href = config.favicon_url || "/logo.svg";
}, [config.favicon_url]);
link.href = config.favicon_url || config.logo_url || "/logo.svg";
}, [config.favicon_url, config.logo_url]);
useEffect(() => {
const media = matchMedia("(prefers-color-scheme: dark)");
@@ -446,7 +447,7 @@ export default function App() {
<div className="theme-background" style={background ? { backgroundImage: `url(${JSON.stringify(background)})`, opacity: 1 } : undefined} />
<header className="site-header">
<div className="container header-inner">
<div className="brand"><img src="/logo.svg" alt="" width="36" height="36" /><strong>{config.site_name}</strong></div>
<div className="brand"><SiteLogo src={config.logo_url} alt="" width="36" height="36" /><strong>{config.site_name}</strong></div>
<div className="header-actions">
<button className="icon-btn" onClick={toggleTheme} title={dark ? ui(config.locale, "浅色主题", "Light theme") : ui(config.locale, "深色主题", "Dark theme")}>{dark ? <Sun size={18} /> : <Moon size={18} />}</button>
<button className="icon-btn" onClick={openAdmin} title={ui(config.locale, "进入后台", "Administration")}><UserCircle size={18} /></button>
@@ -458,21 +459,21 @@ export default function App() {
{config.site_announcement ? <div className="site-announcement"><Megaphone size={16} /><span>{config.site_announcement}</span></div> : null}
{needsLogin ? (
<section className="dashboard-login-gate glass-panel">
<img src="/logo.svg" alt="" width="46" height="46" />
<SiteLogo src={config.logo_url} alt="" width="46" height="46" />
<div className="dashboard-login-copy"><h1>{ui(config.locale, "登录仪表盘", "Sign in to dashboard")}</h1><p>{ui(config.locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}</p></div>
<form className="dashboard-login-form" onSubmit={(event) => void loginDashboard(event)}>
<label><span>{ui(config.locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={loginUsername} onChange={(event) => setLoginUsername(event.target.value)} required /></label>
<label><span>{ui(config.locale, "密码", "Password")}</span><input type="password" autoComplete="current-password" value={loginPassword} onChange={(event) => setLoginPassword(event.target.value)} required /></label>
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} theme={dark ? "dark" : "light"} resetKey={loginTurnstileReset} onVerify={setLoginTurnstileToken} onError={setError} /></div> : null}
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={loginTurnstileReset} onVerify={setLoginTurnstileToken} onError={setError} /></div> : null}
{error ? <p className="form-error">{error}</p> : null}
<button className="primary-btn dashboard-login-submit" disabled={loginBusy || (config.turnstile_login_enabled && !loginTurnstileToken)} type="submit"><KeyRound size={16} />{loginBusy ? ui(config.locale, "登录中", "Signing in") : ui(config.locale, "登录", "Sign in")}</button>
</form>
</section>
) : needsVerification ? (
<section className="verification-gate">
<img src="/logo.svg" alt="" width="52" height="52" />
<SiteLogo src={config.logo_url} alt="" width="52" height="52" />
<div><h1>{ui(config.locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
{config.turnstile_site_key ? <TurnstileWidget siteKey={config.turnstile_site_key} theme={dark ? "dark" : "light"} onVerify={(token) => void verifyDashboard(token)} onError={setError} /> : <p className="form-error">{ui(config.locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
{config.turnstile_site_key ? <TurnstileWidget siteKey={config.turnstile_site_key} action="public-dashboard" theme={dark ? "dark" : "light"} onVerify={(token) => void verifyDashboard(token)} onError={setError} /> : <p className="form-error">{ui(config.locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
{verificationBusy ? <span className="verification-status">{ui(config.locale, "正在验证", "Verifying")}</span> : null}
{error ? <p className="form-error">{error}</p> : null}
</section>
+9
View File
@@ -43,6 +43,15 @@ function AdminApp() {
useEffect(() => {
document.title = config ? `管理面板 · ${config.site_name}` : "管理面板";
}, [config]);
useEffect(() => {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.append(link);
}
link.href = config?.favicon_url || config?.logo_url || "/logo.svg";
}, [config?.favicon_url, config?.logo_url]);
if (error) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}><span>{error}</span><button className="secondary-btn" onClick={() => void loadConfig()}>重试</button></div>;
if (!config) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}>正在加载管理面板</div>;
+20 -15
View File
@@ -37,6 +37,8 @@ import { derivePassword } from "../password";
import { ASSET_CURRENCIES, type AdminServer, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue } from "../types";
import { Checkbox } from "./Checkbox";
import { TurnstileWidget } from "./TurnstileWidget";
import { useDialog } from "./useDialog";
import { SiteLogo } from "./SiteLogo";
import { LatencyManager } from "./LatencyManager";
import { AlertRuleManager } from "./AlertRuleManager";
import { Flag } from "./Flag";
@@ -499,6 +501,8 @@ export function AdminPanel({
}
return `curl -fsSL ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
}, [install, installPlatform]);
const serverDialog = useDialog<HTMLFormElement>(editing !== null, () => setEditing(null));
const installDialog = useDialog<HTMLElement>(Boolean(installCommand), () => setInstall(null));
const toggleSelected = (id: string) => setSelectedIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]);
const allSelected = servers.length > 0 && selectedIds.length === servers.length;
@@ -513,32 +517,33 @@ export function AdminPanel({
setNotice("");
setError("");
};
const siteLogoUrl = settings ? settings.logo_url : config.logo_url;
return (
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
{error ? <div className="admin-toast error" role="alert" aria-live="assertive"><CircleAlert aria-hidden="true" /><span>{error}</span></div>
: notice ? <div className="admin-toast" role="status" aria-live="polite"><CircleCheck aria-hidden="true" /><span>{notice}</span></div> : null}
{!authenticated ? <div className="admin-login-stage">
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={14} />返回仪表盘</button>
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={14} />返回</button>
<button className="admin-login-theme" type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={16} /> : <Moon size={16} />}</button>
<form className="login-form glass-panel" onSubmit={login}>
<img src="/logo.svg" alt="" width="48" height="48" />
<SiteLogo src={siteLogoUrl} alt="" width="48" height="48" />
<div className="login-copy"><h1>管理员登录</h1></div>
<label><span>用户名</span><input autoFocus type="text" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
<label><span>密码</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
{config.turnstile_login_enabled || config.turnstile_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
{config.turnstile_login_enabled || config.turnstile_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
<button className="primary-btn login-submit" disabled={busy || ((config.turnstile_login_enabled || config.turnstile_enabled) && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? "验证中" : "登录"}</button>
</form>
</div> : <section className="admin-shell" aria-label="管理面板">
<header className="admin-topbar">
<div className="admin-brand">
<img src="/logo.svg" alt="" width="36" height="36" />
<SiteLogo src={siteLogoUrl} alt="" width="36" height="36" />
<strong>{config.site_name}</strong>
</div>
<div className="admin-topbar-actions">
<button type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"} aria-label={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button>
<button type="button" onClick={onClose} title="返回前台" aria-label="返回前台"><ArrowLeft size={17} />返回前台</button>
<button type="button" onClick={() => void logout()} title="退出登录" aria-label="退出登录"><LogOut size={17} />退出登录</button>
<button type="button" onClick={onClose} title="主页" aria-label="主页"><ArrowLeft size={17} />主页</button>
<button type="button" onClick={() => void logout()} title="退出" aria-label="退出"><LogOut size={17} />退出</button>
</div>
</header>
@@ -605,20 +610,20 @@ export function AdminPanel({
<div className="form-actions"><button className="primary-btn" disabled={busy}><Plus size={16} />添加主题</button></div>
</form>
</div>
) : settings ? (
) : settings && tab !== "about" ? (
<form className="settings-form" onSubmit={saveSite}>
{tab === "appearance" ? <>
<div className="section-title"><Eye size={15} />外观与展示</div>
<div className="form-grid"><label><span>站点名称</span><input required value={settings.site_name} onChange={(event) => updateSettings("site_name", event.target.value)} /></label><label><span>站点描述</span><input value={settings.site_description} onChange={(event) => updateSettings("site_description", event.target.value)} /></label></div>
<label><span>站点公告</span><textarea rows={3} maxLength={1000} value={settings.site_announcement} onChange={(event) => updateSettings("site_announcement", event.target.value)} /></label>
<div className="form-grid"><label><span>界面语言</span><select value={settings.locale} onChange={(event) => updateSettings("locale", event.target.value as Settings["locale"])}><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label><span>站点图标地址</span><input type="url" value={settings.favicon_url} onChange={(event) => updateSettings("favicon_url", event.target.value)} placeholder="https://example.com/favicon.png" /></label></div>
<div className="form-grid three"><label><span>界面语言</span><select value={settings.locale} onChange={(event) => updateSettings("locale", event.target.value as Settings["locale"])}><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label><span>站点 Logo 地址</span><input type="url" maxLength={1000} value={settings.logo_url} onChange={(event) => updateSettings("logo_url", event.target.value)} placeholder="https://example.com/logo.svg" /></label><label><span>浏览器图标地址</span><input type="url" maxLength={1000} value={settings.favicon_url} onChange={(event) => updateSettings("favicon_url", event.target.value)} placeholder="https://example.com/favicon.png" /></label></div>
<div className="form-grid"><label><span>离线判定(秒)</span><input type="number" min="30" max="3600" value={settings.offline_threshold_seconds} onChange={(event) => updateSettings("offline_threshold_seconds", Number(event.target.value))} /></label><label><span>历史保留(天)</span><input type="number" min="1" max="365" value={settings.history_retention_days} onChange={(event) => updateSettings("history_retention_days", Number(event.target.value))} /></label></div>
</> : null}
{tab === "themeSettings" ? <>
<div className="section-title"><SlidersHorizontal size={15} />通用主题设置</div>
<div className="form-grid"><label><span>默认主题</span><select value={settings.default_theme} onChange={(event) => updateSettings("default_theme", event.target.value as Settings["default_theme"])}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></label><label><span>背景图地址</span><input type="text" value={settings.background_url} onChange={(event) => updateSettings("background_url", event.target.value)} /></label></div>
<p className="settings-hint">背景图可用 | 分隔浅色和深色地址,例如 light.jpg | dark.jpg。</p>
<div className="form-grid"><label><span>默认主题</span><select value={settings.default_theme} onChange={(event) => updateSettings("default_theme", event.target.value as Settings["default_theme"])}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></label><label><span>背景图地址</span><input type="text" maxLength={1000} value={settings.background_url} onChange={(event) => updateSettings("background_url", event.target.value)} placeholder="https://example.com/light.webp | https://example.com/dark.webp" /></label></div>
<p className="settings-hint">仅支持 HTTPS 地址;浅色和深色背景可用 | 分隔。</p>
<div className="section-subtitle">当前主题选项</div><div className="theme-option-grid"><Toggle label="公开仪表盘" checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} />{themeSettingsSchema?.settings.map((field) => <ThemeOption key={field.key} field={field} value={settings.theme_options[field.key] ?? field.default} onChange={(value) => updateThemeOption(field.key, value)} />)}</div>
<div className="section-subtitle">公开界面元素</div>
<div className="settings-toggles"><Toggle label="显示搜索" checked={settings.show_search} onChange={(value) => updateSettings("show_search", value)} /><Toggle label="显示分组" checked={settings.show_groups} onChange={(value) => updateSettings("show_groups", value)} /><Toggle label="总览统计" checked={settings.show_stats} onChange={(value) => updateSettings("show_stats", value)} /><Toggle label="资产统计" checked={settings.show_assets} onChange={(value) => updateSettings("show_assets", value)} /><Toggle label="累计流量" checked={settings.show_traffic} onChange={(value) => updateSettings("show_traffic", value)} /><Toggle label="实时网速" checked={settings.show_speed} onChange={(value) => updateSettings("show_speed", value)} /><Toggle label="价格信息" checked={settings.show_price} onChange={(value) => updateSettings("show_price", value)} /><Toggle label="到期信息" checked={settings.show_expiry} onChange={(value) => updateSettings("show_expiry", value)} /><Toggle label="延迟与丢包" checked={settings.show_latency} onChange={(value) => updateSettings("show_latency", value)} /><Toggle label="在线时长" checked={settings.show_uptime} onChange={(value) => updateSettings("show_uptime", value)} /></div>
@@ -658,11 +663,11 @@ export function AdminPanel({
<p className="settings-hint">清空历史不会删除节点、密钥或最新状态。</p>
</> : null}
{tab !== "data" && tab !== "about" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
</form>
) : tab === "about" ? (
<div className="admin-section about-page">
<div className="about-brand"><img src="/logo.svg" alt="" width="52" height="52" /><div><strong>NodeFlare</strong><small>基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控</small></div></div>
<div className="about-brand"><SiteLogo alt="" width="52" height="52" /><div><strong>NodeFlare</strong><small>基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控</small></div></div>
<div className="about-rows">
<div className="about-row"><span>版本</span><strong>v{VERSION}</strong></div>
<div className="about-row"><span>项目地址</span><a href="https://github.com/imengying/NodeFlare" target="_blank" rel="noreferrer">github.com/imengying/NodeFlare<ExternalLink size={13} /></a></div>
@@ -674,8 +679,8 @@ export function AdminPanel({
</div>
</section>}
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setEditing(null)}><form className="editor-modal glass-panel" onSubmit={saveServer} onMouseDown={(event) => event.stopPropagation()}>
<header><div><span className="eyebrow">节点配置</span><h3>{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}</h3></div></header>
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={serverDialog.onBackdropMouseDown}><form ref={serverDialog.dialogRef} className="editor-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="server-editor-title" tabIndex={-1} onSubmit={saveServer}>
<header><div><span className="eyebrow">节点配置</span><h3 id="server-editor-title">{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}</h3></div></header>
<div className="form-grid"><label><span>名称</span><input autoFocus required value={form.name} onChange={(event) => updateForm("name", event.target.value)} /></label><label><span>地区代码</span><input maxLength={16} placeholder="CN / JP / DE" value={form.region} onChange={(event) => updateForm("region", event.target.value.toUpperCase())} /></label><label><span>分组</span><input value={form.group_name} onChange={(event) => updateForm("group_name", event.target.value)} /></label><label><span>标签</span><input placeholder="主力, 线路:BGP" value={form.tags} onChange={(event) => updateForm("tags", event.target.value)} /></label></div>
<div className="form-grid three"><label><span>流量限额(GB)</span><input min="0" type="number" value={Math.round(form.traffic_limit / 1024 ** 3)} onChange={(event) => updateForm("traffic_limit", Number(event.target.value) * 1024 ** 3)} /></label><label><span>流量口径</span><select value={form.traffic_limit_type} onChange={(event) => updateForm("traffic_limit_type", event.target.value as ServerInput["traffic_limit_type"])}><option value="sum">上下行合计</option><option value="max">取较大值</option><option value="min">取较小值</option><option value="up">仅上行</option><option value="down">仅下行</option></select></label><label><span>流量重置日</span><input min="1" max="31" type="number" value={form.reset_day} onChange={(event) => updateForm("reset_day", Number(event.target.value))} /></label></div>
<div className="form-grid three"><label><span>价格(0 隐藏,-1 免费)</span><input min="-1" step="0.01" type="number" value={form.price} onChange={(event) => updateForm("price", Number(event.target.value))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期(天)</span><input min="1" max="3650" type="number" value={form.billing_cycle} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))} /></label></div>
@@ -685,7 +690,7 @@ export function AdminPanel({
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={16} />保存节点</button></div>
</form></div> : null}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setInstall(null)}><section className="install-modal glass-panel" onMouseDown={(event) => event.stopPropagation()}><header><div><span className="eyebrow">Agent 部署</span><h3>安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={16} />复制</button></div></section></div> : null}
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={16} />复制</button></div></section></div> : null}
</div>
);
}
+4 -2
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react";
import { api } from "../api";
import type { AdminServer, AlertRule, AlertRuleInput } from "../types";
import { Checkbox } from "./Checkbox";
import { useDialog } from "./useDialog";
const emptyRule: AlertRuleInput = {
name: "",
@@ -32,6 +33,7 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
const [form, setForm] = useState<AlertRuleInput>(emptyRule);
const [allServers, setAllServers] = useState(true);
const [busy, setBusy] = useState(false);
const editorDialog = useDialog<HTMLElement>(editing !== null, () => setEditing(null));
const load = useCallback(async () => {
try { setRules((await api.alertRules()).rules); }
@@ -103,8 +105,8 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
{!rules.length ? <div className="list-empty">尚未配置资源告警规则</div> : null}
</div>
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setEditing(null)}><section className="alert-rule-editor glass-panel" onMouseDown={(event) => event.stopPropagation()}>
<header><div><span className="eyebrow">通知规则</span><h3>{editing === "new" ? "新建资源告警" : "编辑资源告警"}</h3></div></header>
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={editorDialog.onBackdropMouseDown}><section ref={editorDialog.dialogRef} className="alert-rule-editor glass-panel" role="dialog" aria-modal="true" aria-labelledby="alert-rule-editor-title" tabIndex={-1}>
<header><div><span className="eyebrow">通知规则</span><h3 id="alert-rule-editor-title">{editing === "new" ? "新建资源告警" : "编辑资源告警"}</h3></div></header>
<label><span>规则名称</span><input required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label>
<div className="form-grid"><label><span>监控指标</span><select value={form.metric} onChange={(event) => setForm((current) => ({ ...current, metric: event.target.value as AlertRuleInput["metric"] }))}>{Object.entries(metricLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label><label><span>阈值({unit})</span><input required type="number" min="0.01" max={unit === "%" ? 100 : 1000000} step="0.01" value={form.threshold} onChange={(event) => setForm((current) => ({ ...current, threshold: Number(event.target.value) }))} /></label></div>
<div className="form-grid"><label><span>时间窗口(分钟)</span><input required type="number" min="1" max="1440" value={form.duration_minutes} onChange={(event) => setForm((current) => ({ ...current, duration_minutes: Number(event.target.value) }))} /></label><label><span>判断方式</span><select value={form.aggregation} onChange={(event) => setForm((current) => ({ ...current, aggregation: event.target.value as AlertRuleInput["aggregation"] }))}><option value="average">窗口平均值</option><option value="continuous">窗口内持续超限</option></select></label></div>
+4 -2
View File
@@ -3,6 +3,7 @@ import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react
import { api } from "../api";
import type { AdminServer, LatencyTask, LatencyTaskInput } from "../types";
import { Checkbox } from "./Checkbox";
import { useDialog } from "./useDialog";
const emptyTask: LatencyTaskInput = {
name: "",
@@ -48,6 +49,7 @@ export function LatencyManager({
const [editing, setEditing] = useState<LatencyTask | "new" | null>(null);
const [form, setForm] = useState<LatencyTaskInput>(emptyTask);
const [query, setQuery] = useState("");
const editorDialog = useDialog<HTMLFormElement>(editing !== null, () => setEditing(null));
const load = useCallback(async () => {
setBusy(true);
@@ -148,8 +150,8 @@ export function LatencyManager({
{busy && !tasks.length ? <div className="list-empty">正在读取延迟任务</div> : null}
</div>
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setEditing(null)}><form className="latency-editor glass-panel" onSubmit={save} onMouseDown={(event) => event.stopPropagation()}>
<header><div><span className="eyebrow">延迟检测</span><h3>{editing === "new" ? "添加任务" : `编辑 · ${editing.name}`}</h3></div></header>
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={editorDialog.onBackdropMouseDown}><form ref={editorDialog.dialogRef} className="latency-editor glass-panel" role="dialog" aria-modal="true" aria-labelledby="latency-editor-title" tabIndex={-1} onSubmit={save}>
<header><div><span className="eyebrow">延迟检测</span><h3 id="latency-editor-title">{editing === "new" ? "添加任务" : `编辑 · ${editing.name}`}</h3></div></header>
<div className="form-grid"><label><span>名称</span><input autoFocus required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label><label><span>类型</span><div className="segmented task-type-control"><button type="button" className={form.task_type === "icmp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "icmp", port: null }))}>ICMP</button><button type="button" className={form.task_type === "tcp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "tcp", port: current.port ?? 80 }))}>TCP</button></div></label></div>
<div className="form-grid"><label><span>节点</span><input required maxLength={50} value={form.target} onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))} placeholder={form.task_type === "tcp" ? "example.com" : "1.1.1.1"} /></label>{form.task_type === "tcp" ? <label><span>端口</span><input type="number" min="1" max="65535" required value={form.port ?? ""} onChange={(event) => setForm((current) => ({ ...current, port: event.target.value ? Number(event.target.value) : null }))} placeholder="80" /></label> : <div />}</div>
<label><span>检测间隔(秒)</span><input type="number" min="30" max="3600" required value={form.interval_seconds} onChange={(event) => setForm((current) => ({ ...current, interval_seconds: Number(event.target.value) }))} /></label>
+15
View File
@@ -0,0 +1,15 @@
import { useState, type ImgHTMLAttributes } from "react";
const DEFAULT_LOGO = "/logo.svg";
interface SiteLogoProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> {
src?: string;
}
export function SiteLogo({ src = "", ...props }: SiteLogoProps) {
const requested = src.trim() || DEFAULT_LOGO;
const [failedSource, setFailedSource] = useState("");
const resolved = failedSource === requested ? DEFAULT_LOGO : requested;
return <img {...props} src={resolved} onError={() => setFailedSource(requested)} />;
}
+4 -1
View File
@@ -34,12 +34,14 @@ function loadScript() {
export function TurnstileWidget({
siteKey,
action,
theme,
resetKey,
onVerify,
onError,
}: {
siteKey: string;
action: "admin-login" | "public-dashboard";
theme: "light" | "dark";
resetKey?: number;
onVerify: (token: string) => void;
@@ -61,6 +63,7 @@ export function TurnstileWidget({
target.current.replaceChildren();
widgetId.current = window.turnstile.render(target.current, {
sitekey: siteKey,
action,
theme,
size: "flexible",
callback: (token: string) => {
@@ -79,7 +82,7 @@ export function TurnstileWidget({
if (widgetId.current && window.turnstile) window.turnstile.remove(widgetId.current);
widgetId.current = "";
};
}, [siteKey, theme, resetKey]);
}, [action, siteKey, theme, resetKey]);
const testing = siteKey.startsWith("1x00000000000000000000");
return <div className={`turnstile-widget${verified ? " verified" : ""}${testing ? " testing" : ""}`} ref={target} />;
+74
View File
@@ -0,0 +1,74 @@
import { type MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef } from "react";
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
function focusableElements(container: HTMLElement) {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
.filter((element) => !element.hidden && element.getClientRects().length > 0);
}
export function useDialog<T extends HTMLElement>(open: boolean, onClose: () => void) {
const dialogRef = useRef<T>(null);
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
if (!open) return;
const dialog = dialogRef.current;
if (!dialog) return;
const dialogElement: HTMLElement = dialog;
const previousFocus = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const focusFrame = window.requestAnimationFrame(() => {
const initial = dialogElement.querySelector<HTMLElement>("[autofocus]")
?? focusableElements(dialogElement)[0]
?? dialogElement;
initial.focus();
});
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
closeRef.current();
return;
}
if (event.key !== "Tab") return;
const focusable = focusableElements(dialogElement);
if (!focusable.length) {
event.preventDefault();
dialogElement.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && (document.activeElement === first || !dialogElement.contains(document.activeElement))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (document.activeElement === last || !dialogElement.contains(document.activeElement))) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
window.cancelAnimationFrame(focusFrame);
document.removeEventListener("keydown", handleKeyDown);
if (previousFocus?.isConnected) previousFocus.focus();
};
}, [open]);
const onBackdropMouseDown = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) closeRef.current();
}, []);
return { dialogRef, onBackdropMouseDown };
}
+1
View File
@@ -4,6 +4,7 @@ export const demoConfig: Config = {
site_name: "NodeFlare",
site_description: "边缘节点与核心服务运行状态",
site_announcement: "",
logo_url: "",
favicon_url: "",
locale: "zh-CN",
public_dashboard: true,
+2 -2
View File
@@ -55,7 +55,7 @@
.admin-login-theme:hover { background: var(--admin-hover); }
.login-form { display: flex; width: min(410px, calc(100% - 32px)); align-items: stretch; flex-direction: column; gap: 16px; border: 1px solid var(--admin-border); border-radius: 8px; padding: 32px; background: var(--admin-surface); }
.login-form img { width: 44px; height: 44px; align-self: center; margin-bottom: 8px; }
.login-form img { width: 44px; height: 44px; align-self: center; margin-bottom: 8px; object-fit: contain; }
.login-copy { text-align: center; }
.login-copy h1 { margin: 0; color: var(--admin-text); font-size: 22px; }
.login-copy p { margin: 5px 0 0; color: var(--admin-muted); font-size: 13px; }
@@ -68,7 +68,7 @@
.admin-shell { display: grid; width: 100%; max-width: 100vw; height: 100dvh; min-width: 0; min-height: 0; overflow: hidden; border: 0; border-radius: 0; background: var(--admin-surface); grid-template-rows: 64px minmax(0, 1fr); }
.admin-topbar { z-index: 2; display: flex; width: 100%; max-width: 100vw; min-width: 0; align-items: center; justify-content: space-between; gap: 24px; overflow: hidden; border-bottom: 1px solid var(--admin-border); padding: 0 24px; background: var(--admin-surface); }
.admin-brand { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 0; color: var(--admin-text); text-align: left; text-decoration: none; }
.admin-brand img { width: 32px; height: 32px; flex: 0 0 auto; }
.admin-brand img { width: 32px; height: 32px; flex: 0 0 auto; object-fit: contain; }
.admin-brand strong { overflow: hidden; max-width: 320px; font-size: 18px; text-overflow: ellipsis; white-space: nowrap; }
.admin-topbar-actions { display: flex; align-items: center; gap: 8px; }
.admin-topbar-actions button { display: inline-flex; min-height: 36px; align-items: center; gap: 6px; border: 1px solid var(--admin-border); border-radius: 6px; padding: 0 11px; color: var(--admin-text); background: var(--admin-surface); font-size: 13px; font-weight: 600; }
+1
View File
@@ -106,6 +106,7 @@
.error-band button { font-weight: 700; }
.empty-state, .chart-loading { display: flex; min-height: 340px; align-items: center; justify-content: center; flex-direction: column; gap: 16px; color: var(--muted-foreground); font-size: 13px; }
.verification-gate { display: flex; width: min(430px, 100%); min-height: 380px; align-items: center; justify-content: center; flex-direction: column; gap: 14px; margin: 12vh auto 0; border: 1px solid var(--border); border-radius: 8px; padding: 30px; background: var(--card); box-shadow: var(--shadow); text-align: center; }
.verification-gate > img, .dashboard-login-gate > img { object-fit: contain; }
.verification-gate h1 { margin: 0; font-size: 19px; }
.verification-gate p { margin: 4px 0 0; color: var(--muted-foreground); font-size: 11px; }
.dashboard-login-gate { display: flex; width: min(420px, 100%); align-items: center; flex-direction: column; gap: 16px; margin: 9vh auto 0; border-radius: 8px; padding: 28px; }
+1
View File
@@ -2,6 +2,7 @@ export interface Config {
site_name: string;
site_description: string;
site_announcement: string;
logo_url: string;
favicon_url: string;
locale: "zh-CN" | "en";
public_dashboard: boolean;
+1
View File
@@ -113,6 +113,7 @@ INSERT INTO settings(id, value, updated_at) VALUES (
'{
"site_description": "轻量、实时的服务器运行状态",
"site_announcement": "",
"logo_url": "",
"favicon_url": "",
"locale": "zh-CN",
"public_dashboard": "true",
+2 -1
View File
@@ -19,7 +19,8 @@
"test:smoke": "sh scripts/smoke-test.sh"
},
"devDependencies": {
"wrangler": "^4.123.0"
"wrangler": "^4.123.0",
"ws": "^8.21.0"
},
"overrides": {
"nanoid": "3.3.18"
+1 -1
View File
@@ -5,7 +5,7 @@ script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
NODEFLARE_VERSION=$(sh "$script_dir/resolve-version.sh")
VITE_NODEFLARE_VERSION=$NODEFLARE_VERSION
export NODEFLARE_VERSION VITE_NODEFLARE_VERSION
export VITE_NODEFLARE_VERSION
cd "$root_dir"
bun run --cwd frontend build
+1 -2
View File
@@ -4,8 +4,7 @@ set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
NODEFLARE_VERSION=$(sh "$script_dir/resolve-version.sh")
VITE_NODEFLARE_VERSION=$NODEFLARE_VERSION
export NODEFLARE_VERSION VITE_NODEFLARE_VERSION
export NODEFLARE_VERSION
cd "$root_dir"
current_build_key=$(sh scripts/build-key.sh worker 2>/dev/null || true)
+11
View File
@@ -11,6 +11,17 @@ fi
if [ -z "$raw" ]; then
raw=$(git -C "$root_dir" describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)
fi
if [ -z "$raw" ]; then
remote_version=$(
git -C "$root_dir" ls-remote --tags --refs origin \
'refs/tags/v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null |
awk '{ sub(/^refs\/tags\//, "", $2); print $2 }' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
sort -V |
tail -n 1
) || remote_version=
raw=$remote_version
fi
if [ -z "$raw" ]; then
raw=$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",*$/\1/p' "$root_dir/package.json" | sed -n '1p')
fi
+2
View File
@@ -109,6 +109,8 @@ server_json=$(request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/admin/servers")
server_id=$(printf '%s' "$server_json" | jq -er '.id')
agent_token=$(printf '%s' "$server_json" | jq -er '.agent_token')
MONITOR_BASE_URL="$MONITOR_BASE_URL" MONITOR_ADMIN_TOKEN="$admin_token" \
MONITOR_AGENT_TOKEN="$agent_token" node scripts/websocket-smoke.mjs
token_without_admin_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
"$MONITOR_BASE_URL/api/admin/servers/$server_id/token")
[ "$token_without_admin_status" = "401" ]
+82
View File
@@ -0,0 +1,82 @@
import WebSocket from "ws";
const baseUrl = process.env.MONITOR_BASE_URL;
const adminToken = process.env.MONITOR_ADMIN_TOKEN;
const agentToken = process.env.MONITOR_AGENT_TOKEN;
if (!baseUrl || !adminToken || !agentToken) {
throw new Error("MONITOR_BASE_URL, MONITOR_ADMIN_TOKEN and MONITOR_AGENT_TOKEN are required");
}
function websocketUrl(path) {
const url = new URL(path, baseUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
function openSocket(path, token) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(websocketUrl(path), {
headers: { Authorization: `Bearer ${token}` },
});
const timer = setTimeout(() => {
socket.terminate();
reject(new Error(`${path} WebSocket handshake timed out`));
}, 5_000);
socket.once("open", () => {
clearTimeout(timer);
resolve(socket);
});
socket.once("unexpected-response", (_request, response) => {
clearTimeout(timer);
reject(new Error(`${path} WebSocket returned HTTP ${response.statusCode}`));
});
socket.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
function waitForMessage(socket, expected) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`WebSocket did not receive ${expected}`)), 5_000);
socket.once("message", (data) => {
clearTimeout(timer);
const message = data.toString();
if (message !== expected) reject(new Error(`Expected ${expected}, received ${message}`));
else resolve();
});
socket.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
function closeSocket(socket) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
socket.terminate();
reject(new Error("WebSocket close timed out"));
}, 5_000);
socket.once("close", () => {
clearTimeout(timer);
resolve();
});
socket.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
socket.close();
});
}
const dashboard = await openSocket("/api/ws", adminToken);
const pong = waitForMessage(dashboard, "pong");
dashboard.send("ping");
await pong;
await closeSocket(dashboard);
const agent = await openSocket("/api/agent/live", agentToken);
await closeSocket(agent);
+9 -107
View File
@@ -1,4 +1,3 @@
use std::cell::RefCell;
use std::collections::HashMap;
use serde::{Deserialize, Serialize, Serializer};
@@ -71,30 +70,6 @@ FROM servers s
LEFT JOIN latest_state m ON m.server_id = s.id
"#;
const SETTINGS_CACHE_TTL_SECONDS: i64 = 30;
#[derive(Clone, PartialEq)]
struct SettingsCacheKey {
default_name: String,
default_threshold: i64,
default_retention: i64,
default_username: String,
}
struct SettingsCacheEntry {
key: SettingsCacheKey,
expires_at: i64,
settings: SettingsView,
}
thread_local! {
static SETTINGS_CACHE: RefCell<Option<SettingsCacheEntry>> = const { RefCell::new(None) };
}
fn invalidate_settings_cache() {
SETTINGS_CACHE.with(|cache| *cache.borrow_mut() = None);
}
pub const SECRET_MASK: &str = "********";
fn secret_for_api(value: &str) -> &str {
@@ -347,6 +322,7 @@ pub struct SettingsView {
pub site_name: String,
pub site_description: String,
pub site_announcement: String,
pub logo_url: String,
pub favicon_url: String,
pub locale: String,
pub public_dashboard: bool,
@@ -1299,22 +1275,6 @@ pub async fn settings(
default_retention: i64,
default_username: &str,
) -> Result<SettingsView> {
let cache_key = SettingsCacheKey {
default_name: default_name.to_string(),
default_threshold,
default_retention,
default_username: default_username.to_string(),
};
let current = now();
if let Some(settings) = SETTINGS_CACHE.with(|cache| {
cache
.borrow()
.as_ref()
.filter(|entry| entry.key == cache_key && entry.expires_at > current)
.map(|entry| entry.settings.clone())
}) {
return Ok(settings);
}
let raw = db
.prepare("SELECT value FROM settings WHERE id = 1")
.first::<String>(Some("value"))
@@ -1322,13 +1282,14 @@ pub async fn settings(
.ok_or_else(|| worker::Error::RustError("站点设置尚未初始化".to_string()))?;
let values: HashMap<String, String> = serde_json::from_str(&raw)
.map_err(|_| worker::Error::RustError("站点设置格式无效".to_string()))?;
let settings = SettingsView {
Ok(SettingsView {
site_name: values
.get("site_name")
.cloned()
.unwrap_or_else(|| default_name.to_string()),
site_description: string_setting(&values, "site_description", "轻量、实时的服务器运行状态"),
site_announcement: string_setting(&values, "site_announcement", ""),
logo_url: string_setting(&values, "logo_url", ""),
favicon_url: string_setting(&values, "favicon_url", ""),
locale: string_setting(&values, "locale", "zh-CN"),
public_dashboard: bool_setting(&values, "public_dashboard", true),
@@ -1377,15 +1338,7 @@ pub async fn settings(
expiry_alert_days: integer_setting(&values, "expiry_alert_days", 7),
cloudflare_account_id: string_setting(&values, "cloudflare_account_id", ""),
cloudflare_api_token: string_setting(&values, "cloudflare_api_token", ""),
};
SETTINGS_CACHE.with(|cache| {
*cache.borrow_mut() = Some(SettingsCacheEntry {
key: cache_key,
expires_at: current + SETTINGS_CACHE_TTL_SECONDS,
settings: settings.clone(),
});
});
Ok(settings)
})
}
pub async fn update_settings(
@@ -1409,6 +1362,9 @@ pub async fn update_settings(
if let Some(value) = input.site_announcement.as_deref() {
push_setting!("site_announcement", value.trim());
}
if let Some(value) = input.logo_url.as_deref() {
push_setting!("logo_url", value.trim());
}
if let Some(value) = input.favicon_url.as_deref() {
push_setting!("favicon_url", value.trim());
}
@@ -1545,7 +1501,6 @@ pub async fn update_settings(
])?
.run()
.await?;
invalidate_settings_cache();
}
Ok(())
}
@@ -1894,7 +1849,6 @@ pub async fn save_setting(db: &D1Database, key: &str, value: &str) -> Result<()>
.bind(&[text(&format!("$.{key}")), text(value), number(now())])?
.run()
.await?;
invalidate_settings_cache();
Ok(())
}
@@ -1907,7 +1861,6 @@ pub async fn increment_setting(db: &D1Database, key: &str) -> Result<()> {
.bind(&[text(&format!("$.{key}")), number(now())])?
.run()
.await?;
invalidate_settings_cache();
Ok(())
}
@@ -1922,12 +1875,10 @@ pub async fn update_expiry(db: &D1Database, id: &str, expires_at: i64) -> Result
#[cfg(test)]
mod tests {
use super::{
alert_window_covered, history_cutoffs, non_empty_string_setting, secret_for_api,
traffic_cycle_key, AlertMetricRow, HistoryMetricAggregate, TrafficCounterState,
SECRET_MASK,
alert_window_covered, secret_for_api, traffic_cycle_key, AlertMetricRow,
HistoryMetricAggregate, TrafficCounterState, SECRET_MASK,
};
use crate::models::AgentReport;
use std::collections::HashMap;
fn row(samples: i64, last_timestamp: i64, report_interval: i64) -> AlertMetricRow {
AlertMetricRow {
@@ -1968,33 +1919,6 @@ mod tests {
assert_eq!(secret_for_api("stored-secret"), SECRET_MASK);
}
#[test]
fn requires_an_explicit_admin_username() {
let mut values = HashMap::from([("admin_username".to_string(), String::new())]);
assert_eq!(
non_empty_string_setting(&values, "admin_username", "operator"),
"operator"
);
assert_eq!(non_empty_string_setting(&values, "admin_username", ""), "");
values.insert("admin_username".to_string(), "owner".to_string());
assert_eq!(
non_empty_string_setting(&values, "admin_username", "operator"),
"owner"
);
}
#[test]
fn honors_short_and_long_history_retention() {
let current = 10_000_000;
let (one_day, one_day_recent) = history_cutoffs(current, 1);
assert_eq!(one_day, current - 86_400);
assert_eq!(one_day_recent, one_day);
let (thirty_days, thirty_days_recent) = history_cutoffs(current, 30);
assert_eq!(thirty_days, current - 30 * 86_400);
assert_eq!(thirty_days_recent, current - 86_400);
}
#[test]
fn assigns_monthly_traffic_periods_with_short_months() {
let timestamp = |year: i64, month: i64, day: i64| {
@@ -2141,26 +2065,4 @@ mod tests {
assert_eq!(point.net_rx_total, 2_000);
assert_eq!(point.net_tx_total, 3_000);
}
#[test]
fn excludes_reports_already_persisted() {
let reports = [
AgentReport {
timestamp: 99,
..AgentReport::default()
},
AgentReport {
timestamp: 100,
..AgentReport::default()
},
AgentReport {
timestamp: 101,
..AgentReport::default()
},
];
let fresh = super::reports_after(&reports, 100);
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].timestamp, 101);
}
}
+9 -19
View File
@@ -2,7 +2,9 @@ use std::{collections::BTreeMap, time::Duration};
use serde::Serialize;
use serde_json::Value;
use worker::{AbortController, D1Database, Error, Fetch, Method, Request, Result};
use worker::{D1Database, Error, Method, Request, Result};
use crate::outbound::fetch_with_timeout;
use crate::{cloudflare, db};
@@ -136,13 +138,11 @@ async fn fetch_json(url: &str) -> Result<Value> {
request
.headers()
.set("User-Agent", "NodeFlare-Exchange-Rates")?;
let controller = AbortController::default();
let signal = controller.signal();
worker::wasm_bindgen_futures::spawn_local(async move {
worker::Delay::from(Duration::from_secs(8)).await;
controller.abort();
});
let mut response = Fetch::Request(request).send_with_signal(&signal).await?;
let Some(mut response) = fetch_with_timeout(request, Duration::from_secs(8)).await? else {
return Err(Error::RustError(
"exchange-rate upstream request timed out".to_string(),
));
};
let status = response.status_code();
if !(200..300).contains(&status) {
return Err(Error::RustError(format!(
@@ -246,7 +246,7 @@ pub async fn refresh(
#[cfg(test)]
mod tests {
use super::{default_rates, due, parse_er_api, parse_frankfurter};
use super::{default_rates, parse_er_api, parse_frankfurter};
#[test]
fn provides_all_frontend_asset_currencies() {
@@ -307,14 +307,4 @@ mod tests {
});
assert!(parse_frankfurter(&wrong_base).is_none());
}
#[test]
fn refreshes_daily_and_retries_hourly() {
let current = 200_000;
assert!(due(0, 0, current, false));
assert!(!due(current - 10, 0, current, false));
assert!(!due(0, current - 10, current, false));
assert!(due(current - 86_400, current - 3_600, current, false));
assert!(due(current, current, current, true));
}
}
+104 -23
View File
@@ -6,6 +6,7 @@ mod latency;
mod live;
mod models;
mod notify;
mod outbound;
mod routes;
mod theme;
mod turnstile;
@@ -18,7 +19,7 @@ use serde::{de::DeserializeOwned, Serialize};
use worker::*;
use crate::auth::{
bearer_token, is_admin, sha256_hex, verify_turnstile_proof, ADMIN_SESSION_SECONDS,
bearer_token, is_admin, random_salt, sha256_hex, verify_turnstile_proof, ADMIN_SESSION_SECONDS,
};
use crate::models::{
AgentDiskMetric, AgentGpuMetric, AgentReport, AlertRuleInput, ApiError, LatencyTaskInput,
@@ -205,7 +206,35 @@ async fn request_json<T: DeserializeOwned>(req: &mut Request, limit: usize) -> R
}
pub(crate) fn error(message: &str, status: u16) -> Result<Response> {
json(&ApiError { error: message }, status)
json(
&ApiError {
error: message,
request_id: None,
},
status,
)
}
fn internal_error(request_id: &str) -> Result<Response> {
let mut response = json(
&ApiError {
error: "服务暂时不可用,请稍后重试",
request_id: Some(request_id),
},
500,
)?;
response.headers_mut().set("X-Request-Id", request_id)?;
Ok(response)
}
fn request_id(req: &Request) -> String {
req.headers()
.get("CF-Ray")
.ok()
.flatten()
.filter(|value| !value.trim().is_empty())
.or_else(random_salt)
.unwrap_or_else(|| format!("local-{}", Date::now().as_millis() as i64))
}
fn rate_limited() -> Result<Response> {
@@ -492,6 +521,38 @@ pub(crate) fn valid_password_derived(value: &str) -> bool {
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}
pub(crate) fn valid_public_asset_url(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return true;
}
if value.chars().count() > 1000 {
return false;
}
Url::parse(value).is_ok_and(|url| {
url.scheme() == "https"
&& url.host_str().is_some_and(|host| !host.is_empty())
&& url.username().is_empty()
&& url.password().is_none()
})
}
pub(crate) fn valid_background_urls(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return true;
}
if value.chars().count() > 1000 {
return false;
}
let parts = value.split('|').collect::<Vec<_>>();
parts.len() <= 2
&& parts.iter().any(|part| !part.trim().is_empty())
&& parts
.iter()
.all(|part| part.trim().is_empty() || valid_public_asset_url(part))
}
fn valid_server_price(value: f64) -> bool {
value.is_finite() && (-1.0..=1_000_000_000.0).contains(&value)
}
@@ -1106,7 +1167,9 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
#[event(fetch, respond_with_errors)]
async fn fetch(req: Request, env: Env, ctx: Context) -> Result<Response> {
let method = req.method();
let method_name = format!("{method:?}");
let path = req.path();
let request_id = request_id(&req);
let request_url = req.url().ok();
let origin = req.headers().get("Origin").ok().flatten();
@@ -1133,8 +1196,17 @@ async fn fetch(req: Request, env: Env, ctx: Context) -> Result<Response> {
match handle(req, env, ctx).await {
Ok(response) => Ok(response),
Err(err) => {
console_error!("request failed: {err}");
error("服务暂时不可用,请检查 D1 迁移与绑定", 500)
console_error!(
"{}",
serde_json::json!({
"event": "request_failed",
"request_id": request_id,
"method": method_name,
"path": path,
"error": err.to_string(),
})
);
internal_error(&request_id)
}
}
}
@@ -1198,9 +1270,9 @@ mod tests {
use super::{
allow_on_rate_limit_failure, history_cache_key, history_cache_seconds, rate_limit_binding,
rewrite_remote_theme_index, same_origin, submitted_secret, valid_agent_mirror,
valid_cloudflare_account_id, valid_cloudflare_api_token, valid_password_derived,
valid_ping_target, valid_server_price, validate_latency_task, validate_server, ADMIN_HTML,
ADMIN_SCRIPT, ADMIN_STYLE,
valid_background_urls, valid_cloudflare_account_id, valid_cloudflare_api_token,
valid_password_derived, valid_ping_target, valid_public_asset_url, validate_latency_task,
validate_server,
};
use crate::{db::SECRET_MASK, models::LatencyTaskInput, models::ServerInput};
use worker::{Method, Url};
@@ -1248,15 +1320,6 @@ mod tests {
assert_eq!(history_cache_seconds(120), 600);
}
#[test]
fn validates_server_prices() {
assert!(valid_server_price(-1.0));
assert!(valid_server_price(0.0));
assert!(valid_server_price(1_000_000_000.0));
assert!(!valid_server_price(-1.01));
assert!(!valid_server_price(f64::NAN));
}
#[test]
fn bounds_samples_buffered_between_reports() {
let mut server = ServerInput {
@@ -1410,13 +1473,31 @@ mod tests {
}
#[test]
fn embeds_the_admin_frontend() {
assert!(ADMIN_HTML.starts_with(b"<!doctype html>"));
assert!(ADMIN_HTML
.windows(b"/admin-assets/admin.js".len())
.any(|value| value == b"/admin-assets/admin.js"));
assert!(!ADMIN_SCRIPT.is_empty());
assert!(!ADMIN_STYLE.is_empty());
fn validates_public_asset_urls() {
assert!(valid_public_asset_url(""));
assert!(valid_public_asset_url("https://cdn.example.com/logo.svg"));
assert!(valid_public_asset_url(
"https://cdn.example.com/background.webp?size=large#hero"
));
assert!(!valid_public_asset_url("http://cdn.example.com/logo.svg"));
assert!(!valid_public_asset_url("/logo.svg"));
assert!(!valid_public_asset_url(
"https://user:pass@example.com/logo.svg"
));
assert!(valid_background_urls(""));
assert!(valid_background_urls(
"https://cdn.example.com/light.webp | https://cdn.example.com/dark.webp"
));
assert!(valid_background_urls(
"https://cdn.example.com/light.webp |"
));
assert!(valid_background_urls("| https://cdn.example.com/dark.webp"));
assert!(!valid_background_urls("|"));
assert!(!valid_background_urls(
"https://cdn.example.com/a.webp | https://cdn.example.com/b.webp | https://cdn.example.com/c.webp"
));
assert!(!valid_background_urls("javascript:alert(1)"));
}
#[test]
+29 -6
View File
@@ -1016,7 +1016,10 @@ impl DurableObject for LiveHub {
reason: String,
_was_clean: bool,
) -> Result<()> {
ws.close(Some(code as u16), Some(reason))
match outbound_close_code(code) {
Some(code) => ws.close(Some(code), Some(reason)),
None => ws.close(None, None::<&str>),
}
}
async fn websocket_error(&self, ws: WebSocket, _error: Error) -> Result<()> {
@@ -1024,6 +1027,11 @@ impl DurableObject for LiveHub {
}
}
fn outbound_close_code(code: usize) -> Option<u16> {
let code = u16::try_from(code).ok()?;
((1000..=4999).contains(&code) && !matches!(code, 1004..=1006 | 1015)).then_some(code)
}
async fn post_live_action(env: &Env, path: &str, action: &str, body: &str) -> Result<Response> {
let namespace = env.durable_object("LIVE_HUB")?;
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
@@ -1126,6 +1134,7 @@ pub async fn disconnect_agents(env: &Env, server_ids: &[String]) -> Result<()> {
pub async fn upgrade(req: Request, env: &Env) -> Result<Response> {
let namespace = env.durable_object("LIVE_HUB")?;
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
let req = req.clone_mut()?;
req.headers().delete("X-Live-Agent-ID")?;
req.headers().delete("X-Live-Agent-Hidden")?;
for header in [
@@ -1157,6 +1166,7 @@ pub async fn upgrade_agent(
) -> Result<Response> {
let namespace = env.durable_object("LIVE_HUB")?;
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
let req = req.clone_mut()?;
req.headers().set("X-Live-Agent-ID", server_id)?;
req.headers()
.set("X-Live-Agent-Hidden", if hidden { "1" } else { "0" })?;
@@ -1204,11 +1214,11 @@ pub async fn upgrade_agent(
mod tests {
use super::{
agent_wss_interval_ms, batch_update_parts_at, begin_d1_flush, cached_update_value,
finish_d1_flush, live_ack_payload, next_d1_write_ms, trim_cached_live_samples,
trim_socket_attachment, update_alert_window, AlertEvaluationServer, AlertWindowSample,
CachedLiveSample, SocketAttachment, TrafficAttachment, ALERT_WINDOW_SECONDS,
CACHED_LIVE_TTL_SECONDS, MAX_CACHED_LIVE_BYTES, MAX_CACHED_LIVE_SAMPLES,
MAX_SERIALIZED_ATTACHMENT_BYTES,
finish_d1_flush, live_ack_payload, next_d1_write_ms, outbound_close_code,
trim_cached_live_samples, trim_socket_attachment, update_alert_window,
AlertEvaluationServer, AlertWindowSample, CachedLiveSample, SocketAttachment,
TrafficAttachment, ALERT_WINDOW_SECONDS, CACHED_LIVE_TTL_SECONDS, MAX_CACHED_LIVE_BYTES,
MAX_CACHED_LIVE_SAMPLES, MAX_SERIALIZED_ATTACHMENT_BYTES,
};
use crate::db::HistoryMetricAggregate;
use crate::latency::LatencyMetricAggregates;
@@ -1228,6 +1238,19 @@ mod tests {
}
}
#[test]
fn filters_reserved_and_invalid_websocket_close_codes() {
assert_eq!(outbound_close_code(1000), Some(1000));
assert_eq!(outbound_close_code(1011), Some(1011));
assert_eq!(outbound_close_code(4000), Some(4000));
assert_eq!(outbound_close_code(999), None);
assert_eq!(outbound_close_code(1004), None);
assert_eq!(outbound_close_code(1005), None);
assert_eq!(outbound_close_code(1006), None);
assert_eq!(outbound_close_code(1015), None);
assert_eq!(outbound_close_code(5000), None);
}
#[test]
fn live_payload_contains_cycle_adjusted_traffic_and_latency() {
let report = AgentReport {
+3
View File
@@ -54,6 +54,7 @@ pub struct SettingsInput {
pub site_name: Option<String>,
pub site_description: Option<String>,
pub site_announcement: Option<String>,
pub logo_url: Option<String>,
pub favicon_url: Option<String>,
pub locale: Option<String>,
pub public_dashboard: Option<bool>,
@@ -366,4 +367,6 @@ pub struct AlertRuleView {
#[derive(Debug, Serialize)]
pub struct ApiError<'a> {
pub error: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<&'a str>,
}
+21
View File
@@ -0,0 +1,21 @@
use std::time::Duration;
use futures_util::{future::select, pin_mut};
use worker::{AbortController, Delay, Fetch, Request, Response, Result};
pub async fn fetch_with_timeout(request: Request, timeout: Duration) -> Result<Option<Response>> {
let controller = AbortController::default();
let signal = controller.signal();
let fetch_request = Fetch::Request(request);
let fetch = fetch_request.send_with_signal(&signal);
let delay = Delay::from(timeout);
pin_mut!(fetch, delay);
match select(fetch, delay).await {
futures_util::future::Either::Left((response, _)) => response.map(Some),
futures_util::future::Either::Right(((), _)) => {
controller.abort();
Ok(None)
}
}
}
+25 -23
View File
@@ -1,6 +1,6 @@
use std::collections::HashSet;
use worker::{console_error, console_warn, Method, Request, Response, Result, Url};
use worker::{console_error, console_warn, Method, Request, Response, Result};
use crate::auth::{
create_admin_jwt, create_theme_preview_proof, hash_password, random_salt, sha256_hex,
@@ -11,9 +11,10 @@ use crate::models::{
};
use crate::routes::{RouteContext, RouteOutcome};
use crate::{
cloudflare, db, error, exchange, json, latency, live, no_content, notify, now, request_json,
server_id, settings_for_admin_response, submitted_secret, theme, valid_cloudflare_account_id,
valid_cloudflare_api_token, valid_password_derived, validate_alert_rule, validate_latency_task,
cloudflare, db, env_secret_text, error, exchange, json, latency, live, no_content, notify, now,
request_json, server_id, settings_for_admin_response, submitted_secret, theme,
valid_background_urls, valid_cloudflare_account_id, valid_cloudflare_api_token,
valid_password_derived, valid_public_asset_url, validate_alert_rule, validate_latency_task,
validate_server, validate_theme, API_JSON_MAX_BYTES,
};
@@ -555,20 +556,12 @@ async fn exchange_rates_refresh(ctx: &RouteContext) -> Result<Response> {
async fn cloudflare_usage(ctx: &RouteContext) -> Result<Response> {
let account_id = if ctx.settings.cloudflare_account_id.trim().is_empty() {
ctx.env
.secret("CF_USAGE_ACCOUNT_ID")
.or_else(|_| ctx.env.var("CF_USAGE_ACCOUNT_ID"))
.map(|value| value.to_string())
.unwrap_or_default()
env_secret_text(&ctx.env, "CF_USAGE_ACCOUNT_ID")
} else {
ctx.settings.cloudflare_account_id.clone()
};
let token = if ctx.settings.cloudflare_api_token.trim().is_empty() {
ctx.env
.secret("CF_USAGE_API_TOKEN")
.or_else(|_| ctx.env.var("CF_USAGE_API_TOKEN"))
.map(|value| value.to_string())
.unwrap_or_default()
env_secret_text(&ctx.env, "CF_USAGE_API_TOKEN")
} else {
ctx.settings.cloudflare_api_token.clone()
};
@@ -624,6 +617,13 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Respons
{
return error("站点公告不能超过 1000 个字符", 400);
}
if input
.logo_url
.as_deref()
.is_some_and(|value| !valid_public_asset_url(value))
{
return error("站点 Logo 必须使用 HTTPS 地址", 400);
}
if input
.default_theme
.as_deref()
@@ -639,10 +639,13 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Respons
}
if input
.background_url
.as_ref()
.is_some_and(|value| value.chars().count() > 1000)
.as_deref()
.is_some_and(|value| !valid_background_urls(value))
{
return error("背景地址过长", 400);
return error(
"背景地址无效:请使用 HTTPS 地址,浅色和深色可用 | 分隔",
400,
);
}
if input.theme_options.as_ref().is_some_and(|value| {
let Some(options) = value.as_object() else {
@@ -751,12 +754,11 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Respons
{
return error("界面语言仅支持简体中文或 English", 400);
}
if input.favicon_url.as_deref().is_some_and(|value| {
!value.trim().is_empty()
&& Url::parse(value.trim())
.ok()
.is_none_or(|url| url.scheme() != "https")
}) {
if input
.favicon_url
.as_deref()
.is_some_and(|value| !valid_public_asset_url(value))
{
return error("站点图标必须使用 HTTPS 地址", 400);
}
let password_hash = if input
+18
View File
@@ -15,6 +15,13 @@ use crate::{
set_admin_session_cookie, store_history_response, API_JSON_MAX_BYTES,
};
fn request_hostname(req: &Request) -> Option<String> {
req.url()
.ok()?
.host_str()
.map(|hostname| hostname.to_string())
}
pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteOutcome> {
let method = req.method();
let path = req.path();
@@ -76,6 +83,7 @@ fn config_value(ctx: &RouteContext) -> serde_json::Value {
"site_name": settings.site_name,
"site_description": settings.site_description,
"site_announcement": settings.site_announcement,
"logo_url": settings.logo_url,
"favicon_url": settings.favicon_url,
"locale": settings.locale,
"public_dashboard": settings.public_dashboard,
@@ -181,10 +189,15 @@ async fn admin_login(req: &mut Request, ctx: &RouteContext) -> Result<Response>
}
let login_turnstile_enabled = ctx.public_turnstile_enabled || ctx.login_protection_enabled;
if login_turnstile_enabled {
let Some(hostname) = request_hostname(req) else {
return error("请求主机名无效", 400);
};
let verified = turnstile::verify(
&input.turnstile_token,
&ctx.turnstile_secret_key,
client_ip(req).as_deref(),
&hostname,
turnstile::ADMIN_LOGIN_ACTION,
)
.await
.unwrap_or(false);
@@ -229,10 +242,15 @@ async fn turnstile_verify(req: &mut Request, ctx: &RouteContext) -> Result<Respo
Ok(value) => value,
Err(_) => return error("验证请求格式无效", 400),
};
let Some(hostname) = request_hostname(req) else {
return error("请求主机名无效", 400);
};
let verified = turnstile::verify(
&input.token,
&ctx.turnstile_secret_key,
client_ip(req).as_deref(),
&hostname,
turnstile::PUBLIC_DASHBOARD_ACTION,
)
.await
.unwrap_or(false);
+1 -12
View File
@@ -368,18 +368,7 @@ pub async fn remote_theme_version(base: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{
asset_url, builtin_settings_schema, normalize_url, resolve_url, validate_settings_schema,
};
#[test]
fn exposes_builtin_glass_theme_settings() {
let schema = builtin_settings_schema();
let settings = schema["settings"].as_array().expect("settings");
assert!(settings.iter().any(|field| field["key"] == "assetCurrency"));
assert!(settings.iter().any(|field| field["key"] == "enableBlur"));
assert_eq!(schema["source"], "builtin");
}
use super::{asset_url, normalize_url, resolve_url, validate_settings_schema};
#[test]
fn validates_remote_theme_settings() {
+88 -6
View File
@@ -1,7 +1,14 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
use worker::{wasm_bindgen::JsValue, Fetch, Method, Request, RequestInit, Result};
use worker::{wasm_bindgen::JsValue, Method, Request, RequestInit, Result};
use crate::outbound::fetch_with_timeout;
const SITEVERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const VERIFY_TIMEOUT_SECONDS: u64 = 8;
pub const ADMIN_LOGIN_ACTION: &str = "admin-login";
pub const PUBLIC_DASHBOARD_ACTION: &str = "public-dashboard";
#[derive(Serialize)]
struct VerifyRequest<'a> {
@@ -11,13 +18,39 @@ struct VerifyRequest<'a> {
remoteip: Option<&'a str>,
}
#[derive(Deserialize)]
#[derive(Debug, Deserialize)]
struct VerifyResponse {
success: bool,
hostname: Option<String>,
action: Option<String>,
}
pub async fn verify(token: &str, secret: &str, remote_ip: Option<&str>) -> Result<bool> {
if token.is_empty() || token.len() > 2048 || secret.is_empty() {
fn matches_request(
response: &VerifyResponse,
expected_hostname: &str,
expected_action: &str,
) -> bool {
response.success
&& response
.hostname
.as_deref()
.is_some_and(|hostname| hostname.eq_ignore_ascii_case(expected_hostname))
&& response.action.as_deref() == Some(expected_action)
}
pub async fn verify(
token: &str,
secret: &str,
remote_ip: Option<&str>,
expected_hostname: &str,
expected_action: &str,
) -> Result<bool> {
if token.is_empty()
|| token.len() > 2048
|| secret.is_empty()
|| expected_hostname.is_empty()
|| expected_action.is_empty()
{
return Ok(false);
}
@@ -32,10 +65,59 @@ pub async fn verify(token: &str, secret: &str, remote_ip: Option<&str>) -> Resul
let request = Request::new_with_init(SITEVERIFY_URL, &init)?;
request.headers().set("Content-Type", "application/json")?;
let mut response = Fetch::Request(request).send().await?;
let Some(mut response) =
fetch_with_timeout(request, Duration::from_secs(VERIFY_TIMEOUT_SECONDS)).await?
else {
return Ok(false);
};
if !(200..300).contains(&response.status_code()) {
return Ok(false);
}
let result: VerifyResponse = response.json().await?;
Ok(result.success)
Ok(matches_request(&result, expected_hostname, expected_action))
}
#[cfg(test)]
mod tests {
use super::{matches_request, VerifyResponse};
fn response(hostname: &str, action: &str) -> VerifyResponse {
VerifyResponse {
success: true,
hostname: Some(hostname.to_string()),
action: Some(action.to_string()),
}
}
#[test]
fn accepts_matching_hostname_and_action() {
assert!(matches_request(
&response("status.example.com", "admin-login"),
"STATUS.EXAMPLE.COM",
"admin-login",
));
}
#[test]
fn rejects_mismatched_or_incomplete_context() {
assert!(!matches_request(
&response("other.example.com", "admin-login"),
"status.example.com",
"admin-login",
));
assert!(!matches_request(
&response("status.example.com", "public-dashboard"),
"status.example.com",
"admin-login",
));
assert!(!matches_request(
&VerifyResponse {
success: true,
hostname: None,
action: None,
},
"status.example.com",
"admin-login",
));
}
}