yggdrasil/api/logs/
sse.rs1use std::convert::Infallible;
13use std::time::Duration;
14
15use axum::extract::Query;
16use axum::http::{HeaderMap, StatusCode};
17use axum::response::sse::{Event, KeepAlive, Sse};
18use futures::StreamExt;
19use serde::Deserialize;
20use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
21use tokio_stream::wrappers::BroadcastStream;
22
23use crate::api::logs::capture::{self, LogRecord};
24use crate::auth::session::parse_session_token;
25use crate::models::log::LogEntry;
26
27#[derive(Deserialize)]
29pub struct LogStreamQuery {
30 pub levels: Option<String>,
31 pub target: Option<String>,
32 pub q: Option<String>,
33}
34
35pub async fn log_stream(
37 headers: HeaderMap,
38 Query(q): Query<LogStreamQuery>,
39) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, (StatusCode, String)> {
40 let cookie_header = headers
42 .get("cookie")
43 .and_then(|h| h.to_str().ok())
44 .unwrap_or("");
45 let token = parse_session_token(cookie_header).map(str::to_string);
46 let token = match token {
47 Some(t) => t,
48 None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())),
49 };
50 let user = match crate::api::auth::get_user_by_token(&token).await {
51 Ok(Some(u)) => u,
52 _ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())),
53 };
54 if user.role != crate::models::user::UserRole::Admin {
55 return Err((StatusCode::FORBIDDEN, "权限不足".to_string()));
56 }
57
58 let levels: Vec<String> = q
61 .levels
62 .unwrap_or_default()
63 .split(',')
64 .map(|l| l.trim().to_uppercase())
65 .filter(|l| super::VALID_LEVELS.contains(&l.as_str()))
66 .collect();
67 let target = q
68 .target
69 .map(|t| t.trim().to_string())
70 .filter(|t| !t.is_empty());
71 let needle =
73 q.q.map(|s| s.trim().to_lowercase())
74 .filter(|s| !s.is_empty());
75
76 let stream = BroadcastStream::new(capture::subscribe_live()).filter_map(move |item| {
77 std::future::ready(
78 match item {
79 Ok(record) => {
80 if !matches_filters(&record, &levels, &target, &needle) {
81 None
82 } else {
83 let entry = LogEntry {
84 id: 0,
86 ts: record.ts,
87 level: record.level,
88 target: record.target,
89 message: record.message,
90 };
91 Some(
92 Event::default()
93 .event("log")
94 .json_data(entry)
95 .unwrap_or_else(|_| Event::default().event("log").data("{}")),
96 )
97 }
98 }
99 Err(BroadcastStreamRecvError::Lagged(n)) => Some(
101 Event::default()
102 .event("gap")
103 .data(format!("missed {n} log events")),
104 ),
105 }
106 .map(Ok),
107 )
108 });
109
110 Ok(Sse::new(stream).keep_alive(
111 KeepAlive::new()
112 .interval(Duration::from_secs(15))
113 .text("keep-alive"),
114 ))
115}
116
117fn matches_filters(
119 record: &LogRecord,
120 levels: &[String],
121 target: &Option<String>,
122 needle: &Option<String>,
123) -> bool {
124 if !levels.is_empty() && !levels.iter().any(|l| l == &record.level) {
125 return false;
126 }
127 if let Some(t) = target {
128 if &record.target != t {
129 return false;
130 }
131 }
132 if let Some(n) = needle {
133 if !record.message.to_lowercase().contains(n.as_str()) {
134 return false;
135 }
136 }
137 true
138}
139
140#[cfg(all(test, feature = "server"))]
141mod tests {
142 use super::*;
143
144 fn record(level: &str, target: &str, message: &str) -> LogRecord {
145 LogRecord {
146 ts: chrono::Utc::now(),
147 level: level.to_string(),
148 target: target.to_string(),
149 message: message.to_string(),
150 }
151 }
152
153 #[test]
154 fn empty_filters_match_everything() {
155 let r = record("INFO", "yggdrasil::api::posts", "hello");
156 assert!(matches_filters(&r, &[], &None, &None));
157 }
158
159 #[test]
160 fn level_filter_matches_whitelist() {
161 let r = record("WARN", "t", "m");
162 let levels = vec!["ERROR".to_string(), "WARN".to_string()];
163 assert!(matches_filters(&r, &levels, &None, &None));
164 let levels = vec!["ERROR".to_string()];
165 assert!(!matches_filters(&r, &levels, &None, &None));
166 }
167
168 #[test]
169 fn target_filter_is_exact() {
170 let r = record("INFO", "yggdrasil::api::posts", "m");
171 assert!(matches_filters(
172 &r,
173 &[],
174 &Some("yggdrasil::api::posts".to_string()),
175 &None
176 ));
177 assert!(!matches_filters(
178 &r,
179 &[],
180 &Some("yggdrasil::api".to_string()),
181 &None
182 ));
183 }
184
185 #[test]
186 fn query_filter_is_case_insensitive_substring() {
187 let r = record("INFO", "t", "Database Migration Failed");
188 assert!(matches_filters(
189 &r,
190 &[],
191 &None,
192 &Some("migration failed".to_string())
193 ));
194 assert!(!matches_filters(
195 &r,
196 &[],
197 &None,
198 &Some("backup".to_string())
199 ));
200 }
201}