Skip to main content

pacenotes_server/
routes.rs

1//! The HTTP surface. Handlers stay thin: access rules live in `auth`, package
2//! lookups in `store`.
3
4pub 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
12/// Serve it with `into_make_service_with_connect_info::<SocketAddr>()`, since
13/// the report limiter needs the peer address.
14pub 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        // An unauthenticated write, capped before anything parses it.
25        .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    /// Absent means every game in one document.
43    game: Option<String>,
44}
45
46/// Never 4xx: an unusable credential silently gets the anonymous listing, so
47/// probing cannot reveal directory names, and so does a failed credential
48/// lookup, since the public catalogue is still right.
49async 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    // The listing differs per key, so no shared cache may hand one to another caller.
67    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
76/// The status never reveals what a protected directory holds: an impossible
77/// key is 404, a protected directory without a live key is 401 whether or not
78/// the package exists, and a missing grant answers the same 404 as a missing
79/// package. Public packages never touch the auth database, so a database
80/// outage cannot take the free catalogue down.
81async 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    // Checked after authorization, so a protected validator is never confirmed to a stranger.
111    let etag = format!("\"{}\"", package.sha256);
112    let cache_control = if directory == pacenotes_shared::PUBLIC_DIR {
113        // A republish changes a URL's bytes, so caches revalidate rather than trust an age.
114        "no-cache"
115    } else {
116        // The key travels in a header, so a cache keyed on the URL would leak the zip.
117        "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    // Bytes of another size cannot match the client's sha, and would only read as corruption.
143    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
170/// Whether `If-None-Match` names this ETag, in a list, as a weak validator
171/// (which matches for a GET) or as `*`.
172fn 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
184/// Names the LEAF, since a whole name contains `/`: an ASCII `filename` that
185/// cannot close its quotes, and the exact name as RFC 6266 `filename*`.
186fn 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    /// Absent means the fallback language.
213    #[serde(default)]
214    lang: String,
215}
216
217/// No auth; 404 when there is no text at all, which the installer treats as being offline.
218async 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
234/// `POST /report/conflict`: no auth, since users without a key hit conflicts too.
235///
236/// The rate is checked before the body is parsed, so a flood costs a map
237/// lookup; parsing by hand gives a malformed body an `ApiError` like any other
238/// failure, and success is an empty 202 that echoes nothing.
239async 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
277/// `GET /whoami`: 200 with `shared::KeyStatus` for a live key, 401 for every
278/// other, which is how a client tells a mistyped key from a good one.
279async 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
299/// The caller's credential: `None` for anonymous, malformed and unknown keys
300/// alike, `Err` when the lookup itself failed, which each caller handles.
301async 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}