1use crate::AppState;
2use axum::{
3 extract::{Path, Query, State},
4 http::{header, HeaderMap, StatusCode},
5 response::{IntoResponse, Response},
6 Json,
7};
8use moonlight_core::{
9 report::{render_report, ReportFormat},
10 review::{ReviewUpdate, RunReviewState},
11 Adapter, Classification, ComparisonRun, MetricsSnapshot, RunFilter, RunPage,
12};
13use std::sync::Arc;
14use uuid::Uuid;
15
16pub async fn get_health() -> impl IntoResponse {
17 Json(serde_json::json!({ "ok": true }))
18}
19
20pub async fn get_config(
21 State(state): State<Arc<AppState>>,
22 headers: HeaderMap,
23) -> Result<Json<moonlight_core::config::AppConfig>, StatusCode> {
24 require_admin(&state, &headers)?;
25 Ok(Json(state.config.clone()))
26}
27
28#[derive(Debug, serde::Deserialize)]
29pub struct RunsQuery {
30 limit: Option<usize>,
31 offset: Option<usize>,
32 classification: Option<String>,
33 adapter: Option<String>,
34 q: Option<String>,
35 status: Option<u16>,
36 has_noise: Option<bool>,
37 has_diff: Option<bool>,
38}
39
40pub async fn get_runs(
41 State(state): State<Arc<AppState>>,
42 headers: HeaderMap,
43 Query(query): Query<RunsQuery>,
44) -> Result<Json<RunPage>, StatusCode> {
45 require_admin(&state, &headers)?;
46 refresh_storage(&state).await;
47 let limit = query.limit.unwrap_or(100).min(1000);
48 let offset = query.offset.unwrap_or(0);
49 let filter = query.into_filter().map_err(|_| StatusCode::BAD_REQUEST)?;
50 Ok(Json(
51 state.storage.filtered_page(&filter, limit, offset).await,
52 ))
53}
54
55pub async fn get_run(
56 State(state): State<Arc<AppState>>,
57 headers: HeaderMap,
58 Path(id): Path<Uuid>,
59) -> Result<Json<ComparisonRun>, StatusCode> {
60 require_admin(&state, &headers)?;
61 refresh_storage(&state).await;
62 state
63 .storage
64 .get(id)
65 .await
66 .map(Json)
67 .ok_or(StatusCode::NOT_FOUND)
68}
69
70pub async fn get_stats(
71 State(state): State<Arc<AppState>>,
72 headers: HeaderMap,
73) -> Result<Json<moonlight_core::StatsSummary>, StatusCode> {
74 require_admin(&state, &headers)?;
75 refresh_storage(&state).await;
76 Ok(Json(state.storage.stats().await))
77}
78
79pub async fn get_metrics(
80 State(state): State<Arc<AppState>>,
81 headers: HeaderMap,
82) -> Result<Json<MetricsSnapshot>, StatusCode> {
83 require_admin(&state, &headers)?;
84 Ok(Json(state.metrics.snapshot()))
85}
86
87#[derive(Debug, serde::Deserialize)]
88pub struct ReportQuery {
89 format: Option<String>,
90}
91
92pub async fn get_run_report(
93 State(state): State<Arc<AppState>>,
94 headers: HeaderMap,
95 Path(id): Path<Uuid>,
96 Query(query): Query<ReportQuery>,
97) -> Result<Response, StatusCode> {
98 require_admin(&state, &headers)?;
99 refresh_storage(&state).await;
100 let run = state.storage.get(id).await.ok_or(StatusCode::NOT_FOUND)?;
101 let format = query
102 .format
103 .as_deref()
104 .unwrap_or("markdown")
105 .parse::<ReportFormat>()
106 .map_err(|_| StatusCode::BAD_REQUEST)?;
107 let content = render_report(&run, Some(&state.config), format)
108 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
109 let content_type = match format {
110 ReportFormat::Markdown => "text/markdown; charset=utf-8",
111 ReportFormat::Json => "application/json; charset=utf-8",
112 };
113 Ok(([(header::CONTENT_TYPE, content_type)], content).into_response())
114}
115
116pub async fn get_run_review(
117 State(state): State<Arc<AppState>>,
118 headers: HeaderMap,
119 Path(id): Path<Uuid>,
120) -> Result<Json<RunReviewState>, StatusCode> {
121 require_admin(&state, &headers)?;
122 Ok(Json(state.review_store.get(id).await))
123}
124
125pub async fn put_run_review(
126 State(state): State<Arc<AppState>>,
127 headers: HeaderMap,
128 Path(id): Path<Uuid>,
129 Json(update): Json<ReviewUpdate>,
130) -> Result<Json<RunReviewState>, StatusCode> {
131 require_admin(&state, &headers)?;
132 state
133 .review_store
134 .put(id, update)
135 .await
136 .map(Json)
137 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
138}
139
140async fn refresh_storage(state: &AppState) {
141 if let Err(error) = state.storage.refresh().await {
142 state.metrics.record_storage_refresh_failure();
143 tracing::warn!(error = %error, "failed to refresh moonlight run storage");
144 }
145}
146
147fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<(), StatusCode> {
148 let Some(token) = state.config.admin_token.as_deref() else {
149 return Ok(());
150 };
151
152 if bearer_token_matches(headers, token) || header_token_matches(headers, token) {
153 Ok(())
154 } else {
155 Err(StatusCode::UNAUTHORIZED)
156 }
157}
158
159impl RunsQuery {
160 fn into_filter(self) -> anyhow::Result<RunFilter> {
161 Ok(RunFilter {
162 classification: self
163 .classification
164 .map(|value| value.parse::<Classification>())
165 .transpose()?,
166 adapter: self
167 .adapter
168 .map(|value| value.parse::<Adapter>())
169 .transpose()?,
170 query: self.q,
171 status: self.status,
172 has_noise: self.has_noise,
173 has_diff: self.has_diff,
174 })
175 }
176}
177
178fn bearer_token_matches(headers: &HeaderMap, token: &str) -> bool {
179 headers
180 .get(axum::http::header::AUTHORIZATION)
181 .and_then(|value| value.to_str().ok())
182 .and_then(|value| value.strip_prefix("Bearer "))
183 .is_some_and(|value| value == token)
184}
185
186fn header_token_matches(headers: &HeaderMap, token: &str) -> bool {
187 headers
188 .get("x-moonlight-admin-token")
189 .and_then(|value| value.to_str().ok())
190 .is_some_and(|value| value == token)
191}