1pub struct AppState {
5 pub store: crate::store::ManifestStore,
6 pub db: libsql::Connection,
7 pub reports: crate::reports::Reports,
8}
9
10type SharedState = axum::extract::State<std::sync::Arc<AppState>>;
11
12pub fn router(state: std::sync::Arc<AppState>) -> axum::Router {
15 axum::Router::new()
16 .route("/health", axum::routing::get(health))
17 .route("/packages", axum::routing::get(list_packages))
18 .route(
19 "/download/{game}/{directory}/{*name}",
20 axum::routing::get(download),
21 )
22 .route("/motd", axum::routing::get(motd))
23 .route("/whoami", axum::routing::get(whoami))
24 .route(
26 "/report/conflict",
27 axum::routing::post(report_conflict).layer(axum::extract::DefaultBodyLimit::max(
28 crate::reports::MAX_BODY_BYTES,
29 )),
30 )
31 .with_state(state)
32 .layer(axum::middleware::from_fn(crate::access_log::log_response))
33 .layer(tower_http::trace::TraceLayer::new_for_http())
34}
35
36async fn health() -> &'static str {
37 "ok"
38}
39
40#[derive(serde::Deserialize)]
41pub struct PackagesQuery {
42 game: Option<String>,
44}
45
46async fn list_packages(
50 axum::extract::State(state): SharedState,
51 axum::extract::Query(query): axum::extract::Query<PackagesQuery>,
52 headers: axum::http::HeaderMap,
53) -> Result<axum::response::Response, crate::error::AppError> {
54 let grants = match caller(&state, &headers).await {
55 Ok(Some(token)) if token.is_live(crate::auth::now()) => token.grants,
56 Ok(_) => Vec::new(),
57 Err(e) => {
58 tracing::error!(error = %e, "credential lookup failed; serving the public listing");
59 Vec::new()
60 }
61 };
62 let mut manifest = state.store.current().visible_to(&grants);
63 if let Some(game) = query.game.as_deref() {
64 manifest = manifest.for_game(game);
65 }
66 Ok(axum::response::IntoResponse::into_response((
68 [
69 (axum::http::header::CACHE_CONTROL, "private, no-store"),
70 (axum::http::header::VARY, "Authorization"),
71 ],
72 axum::Json(manifest),
73 )))
74}
75
76async fn download(
82 axum::extract::State(state): SharedState,
83 axum::extract::Path((game, directory, name)): axum::extract::Path<(String, String, String)>,
84 headers: axum::http::HeaderMap,
85) -> Result<axum::response::Response, crate::error::AppError> {
86 if !pacenotes_shared::valid_id(&game)
87 || !pacenotes_shared::valid_id(&directory)
88 || !pacenotes_shared::valid_name(&name)
89 {
90 return Err(crate::error::AppError::not_found());
91 }
92 let found = state.store.zip_path(&game, &directory, &name);
93
94 if directory != pacenotes_shared::PUBLIC_DIR {
95 let token = caller(&state, &headers)
96 .await
97 .map_err(|e| crate::error::AppError::internal("loading credential", e))?;
98 match crate::auth::decide(token.as_ref(), &directory, crate::auth::now()) {
99 crate::auth::Decision::Allow => {}
100 crate::auth::Decision::Unauthorized => {
101 return Err(crate::error::AppError::unauthorized())
102 }
103 crate::auth::Decision::Hidden => return Err(crate::error::AppError::not_found()),
104 }
105 }
106 let Some((package, path)) = found else {
107 return Err(crate::error::AppError::not_found());
108 };
109
110 let etag = format!("\"{}\"", package.sha256);
112 let cache_control = if directory == pacenotes_shared::PUBLIC_DIR {
113 "no-cache"
115 } else {
116 "private, no-store"
118 };
119 if if_none_match(&headers, &etag) {
120 return Ok(axum::response::IntoResponse::into_response((
121 axum::http::StatusCode::NOT_MODIFIED,
122 [
123 (axum::http::header::ETAG, etag),
124 (axum::http::header::CACHE_CONTROL, cache_control.to_string()),
125 ],
126 )));
127 }
128
129 let file = match tokio::fs::File::open(&path).await {
130 Ok(file) => file,
131 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
132 tracing::warn!(path = %path.display(), "manifest entry without a file on disk");
133 return Err(crate::error::AppError::not_found());
134 }
135 Err(e) => return Err(crate::error::AppError::internal("opening package", e)),
136 };
137 let size = file
138 .metadata()
139 .await
140 .map_err(|e| crate::error::AppError::internal("stat package", e))?
141 .len();
142 if size != package.size {
144 tracing::error!(
145 path = %path.display(),
146 on_disk = size,
147 manifest = package.size,
148 "package size differs from the manifest; refusing to serve it"
149 );
150 return Err(crate::error::AppError::unavailable());
151 }
152
153 let body = axum::body::Body::from_stream(tokio_util::io::ReaderStream::new(file));
154 let headers = [
155 (
156 axum::http::header::CONTENT_TYPE,
157 "application/zip".to_string(),
158 ),
159 (axum::http::header::CONTENT_LENGTH, size.to_string()),
160 (
161 axum::http::header::CONTENT_DISPOSITION,
162 content_disposition(package.leaf()),
163 ),
164 (axum::http::header::ETAG, etag),
165 (axum::http::header::CACHE_CONTROL, cache_control.to_string()),
166 ];
167 Ok(axum::response::IntoResponse::into_response((headers, body)))
168}
169
170fn if_none_match(headers: &axum::http::HeaderMap, etag: &str) -> bool {
173 headers
174 .get_all(axum::http::header::IF_NONE_MATCH)
175 .iter()
176 .filter_map(|value| value.to_str().ok())
177 .flat_map(|value| value.split(','))
178 .map(str::trim)
179 .any(|candidate| {
180 candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag
181 })
182}
183
184fn content_disposition(leaf: &str) -> String {
187 let ascii: String = leaf
188 .chars()
189 .map(|c| {
190 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ' ') {
191 c
192 } else {
193 '_'
194 }
195 })
196 .collect();
197 let encoded: String = leaf
198 .bytes()
199 .map(|byte| {
200 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
201 (byte as char).to_string()
202 } else {
203 format!("%{byte:02X}")
204 }
205 })
206 .collect();
207 format!("attachment; filename=\"{ascii}.zip\"; filename*=UTF-8''{encoded}.zip")
208}
209
210#[derive(serde::Deserialize)]
211pub struct MotdQuery {
212 #[serde(default)]
214 lang: String,
215}
216
217async fn motd(
219 axum::extract::State(state): SharedState,
220 axum::extract::Query(query): axum::extract::Query<MotdQuery>,
221) -> Result<axum::response::Response, crate::error::AppError> {
222 let Some(text) = state.store.motd(&query.lang) else {
223 return Err(crate::error::AppError::not_found());
224 };
225 Ok(axum::response::IntoResponse::into_response((
226 [(
227 axum::http::header::CONTENT_TYPE,
228 "text/plain; charset=utf-8",
229 )],
230 text,
231 )))
232}
233
234async fn report_conflict(
240 axum::extract::State(state): SharedState,
241 axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
242 headers: axum::http::HeaderMap,
243 body: axum::body::Bytes,
244) -> Result<axum::response::Response, crate::error::AppError> {
245 let client = state.reports.client_ip(peer.ip(), &headers);
246 if let Err(retry_after) = state.reports.check_rate(client) {
247 return Err(crate::error::AppError::rate_limited(retry_after));
248 }
249
250 let report: pacenotes_shared::ConflictReport = serde_json::from_slice(&body)
251 .map_err(|_| crate::error::AppError::invalid_report("the body is not a conflict report"))?;
252 let valid = crate::reports::validate(report).map_err(crate::error::AppError::invalid_report)?;
253 let record = crate::reports::Record {
254 received_at: crate::auth::now(),
255 game: valid.game,
256 installer: valid.installer,
257 conflicts: valid.conflicts,
258 dropped: valid.dropped,
259 };
260 state
261 .reports
262 .append(&record)
263 .await
264 .map_err(|e| crate::error::AppError::internal("storing a conflict report", e))?;
265 tracing::info!(
266 game = %record.game,
267 installer = %record.installer,
268 conflicts = record.conflicts.len(),
269 dropped = record.dropped,
270 "conflict report stored"
271 );
272 Ok(axum::response::IntoResponse::into_response(
273 axum::http::StatusCode::ACCEPTED,
274 ))
275}
276
277async fn whoami(
280 axum::extract::State(state): SharedState,
281 headers: axum::http::HeaderMap,
282) -> Result<axum::response::Response, crate::error::AppError> {
283 let token = caller(&state, &headers)
284 .await
285 .map_err(|e| crate::error::AppError::internal("loading credential", e))?;
286 match token {
287 Some(token) if token.is_live(crate::auth::now()) => {
288 Ok(axum::response::IntoResponse::into_response((
289 [(axum::http::header::CACHE_CONTROL, "no-store")],
290 axum::Json(pacenotes_shared::KeyStatus {
291 expires_at: token.expires_at,
292 }),
293 )))
294 }
295 _ => Err(crate::error::AppError::invalid_key()),
296 }
297}
298
299async fn caller(
302 state: &AppState,
303 headers: &axum::http::HeaderMap,
304) -> anyhow::Result<Option<crate::auth::TokenRow>> {
305 let Some(raw) = crate::auth::bearer_from_headers(headers) else {
306 return Ok(None);
307 };
308 if !crate::auth::well_formed(&raw) {
309 return Ok(None);
310 }
311 crate::auth::load_token(&state.db, &crate::auth::hash_token(&raw)).await
312}
313
314#[cfg(test)]
315mod tests {
316 #[test]
317 fn content_disposition_names_the_leaf_safely() {
318 assert_eq!(
319 super::content_disposition("Kamil"),
320 "attachment; filename=\"Kamil.zip\"; filename*=UTF-8''Kamil.zip"
321 );
322 assert_eq!(
323 super::content_disposition("Numeric Standard"),
324 "attachment; filename=\"Numeric Standard.zip\"; \
325 filename*=UTF-8''Numeric%20Standard.zip"
326 );
327 assert_eq!(
328 super::content_disposition("Wiśniewski"),
329 "attachment; filename=\"Wi_niewski.zip\"; filename*=UTF-8''Wi%C5%9Bniewski.zip"
330 );
331 }
332
333 #[test]
334 fn a_quote_or_backslash_cannot_close_the_filename_early() {
335 let hostile = super::content_disposition("a\"b\\c");
336 assert!(
337 hostile.starts_with("attachment; filename=\"a_b_c.zip\";"),
338 "{hostile}"
339 );
340 }
341
342 #[test]
343 fn if_none_match_handles_lists_weak_tags_and_star() {
344 let etag = "\"abc\"";
345 let with = |value: &str| {
346 let mut headers = axum::http::HeaderMap::new();
347 headers.insert(
348 axum::http::header::IF_NONE_MATCH,
349 axum::http::HeaderValue::from_str(value).unwrap(),
350 );
351 headers
352 };
353 for matching in ["\"abc\"", "\"x\", \"abc\"", "W/\"abc\"", "*"] {
354 assert!(super::if_none_match(&with(matching), etag), "{matching}");
355 }
356 assert!(!super::if_none_match(&with("\"abd\""), etag));
357 assert!(!super::if_none_match(&axum::http::HeaderMap::new(), etag));
358 }
359}