diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 594cc16..cf7b899 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -115,6 +115,14 @@ use tracing_subscriber::EnvFilter; /// to every connected client. The dumb-forwarder property is doing real work here: the sidecar /// does not know what an asset is, and must not learn. /// +/// Phase 2 adds the first family that actually carries content: **`cliloc.table`**, served at +/// `GET /cliloc`. It pages — the shard cuts at a byte budget and the caller echoes a cursor back — +/// and the sidecar forwards it without keeping any of it, which matters more here than usual: the +/// payload is five megabytes of EA's strings out of the operator's own client, and the one copy of +/// it that should exist is the one the website imports. That phase also gave `assets.error` a +/// `code`, so the status a refusal maps to stops depending on the wording of a human-facing +/// sentence (see [`web::asset_error_status`]). +/// /// **No store migration**, again: nothing on this plane is an event, so nothing is persisted. pub const PROTOCOL_VERSION: u32 = 8; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 5902a04..5c7d384 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -141,6 +141,11 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // the files on that host changed since the last import", and a cached answer to that is // worse than no answer. .route("/assets/sources", get(assets_sources)) + // The cliloc table (Protocol 8, phase 2): UO's id -> display-string map, read out of the + // shard's own client and paged. RPC for the same reason as the source gate, and one more: + // it is five megabytes of somebody else's copyrighted strings, which this process has no + // business holding a copy of. It forwards them and forgets them. + .route("/cliloc", get(cliloc_table)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -1345,18 +1350,7 @@ fn respond_assets(result: Result) -> (StatusCode, Json) if kind == BUSY_KIND { (BUSY_STATUS, Json(value)) } else if kind == "assets.error" { - let reason = value - .get("reason") - .and_then(|r| r.as_str()) - .unwrap_or("request rejected"); - - let code = if reason.contains("disabled") { - StatusCode::FORBIDDEN - } else { - StatusCode::BAD_REQUEST - }; - - (code, Json(value)) + (asset_error_status(&value), Json(value)) } else { (StatusCode::OK, Json(value)) } @@ -1372,6 +1366,85 @@ fn respond_assets(result: Result) -> (StatusCode, Json) } } +/// The status behind one `assets.error`. +/// +/// Phase 2 gave the frame a `code`, and the reason is worth stating: phase 1 decided between 403 +/// and 400 by looking for the word "disabled" **in the operator-facing sentence**. That works +/// until someone improves the wording, at which point a refusal quietly becomes a bad request and +/// an administrator goes hunting for a bug in a correctly-written call. The sentence is for a +/// human; the code is for this function. +/// +/// The substring check survives as a fallback because an overlay is deployed independently of the +/// sidecar: a phase-1 shard paired with a phase-2 sidecar still sends the codeless frame, and it +/// must keep getting its 403. +fn asset_error_status(value: &Value) -> StatusCode { + match value.get("code").and_then(|c| c.as_str()) { + Some("DISABLED") => return StatusCode::FORBIDDEN, + // The shard has no such file. Not the caller's mistake and not a broken shard -- a client + // that does not carry what was asked for, which the website reports to its operator. + Some("NOT_FOUND") => return StatusCode::NOT_FOUND, + // It has the file and cannot decode it: truncated, hand-edited, or not what it claims to + // be. 422 rather than 400, because the request was fine and repeating it will not help. + Some("UNREADABLE") => return StatusCode::UNPROCESSABLE_ENTITY, + // The shard cannot do this right now (it could not start its asset worker, say). Same + // status as "no shard connected", because it means the same thing to a caller: come back. + Some("UNAVAILABLE") => return StatusCode::SERVICE_UNAVAILABLE, + Some("BAD_REQUEST") => return StatusCode::BAD_REQUEST, + _ => {} + } + + let reason = value + .get("reason") + .and_then(|r| r.as_str()) + .unwrap_or("request rejected"); + + if reason.contains("disabled") { + StatusCode::FORBIDDEN + } else { + StatusCode::BAD_REQUEST + } +} + +/// The cliloc table, as the shard's own client holds it (docs/link/v8.md §9). +/// +/// Until protocol 8 this table reached the website by hand: the operator installed UOFiddler, +/// built a converter against its `Ultima.dll`, ran it over their client's compressed `Cliloc.enu` +/// and copied the result to the web host. The shard could not help, because ServUO's bundled +/// `Ultima.StringList` cannot read a modern client's file either. Phase 2 put the decompressor in +/// the overlay, so the shard reads its own client and the operator installs nothing. +/// +/// **Paged, and the caller drives the paging** -- `?cursor=` echoes back whatever the previous +/// reply's `cursor` was, until a reply says `more: false`. The pages are cut by byte budget on the +/// shard (512 KiB against the 1 MiB inbound line cap), so a stock English table arrives in about +/// eleven of them. The sidecar keeps none of it: it has no opinion about what a cliloc is, and a +/// cached copy of five megabytes of EA's strings is exactly what this process should not hold. +/// +/// `?lang=` selects the file; it defaults to `enu` on the shard and the shard refuses anything its +/// `Ultima.Files` cannot resolve, which is a 404 rather than a 400. +async fn cliloc_table( + State(st): State, + Query(q): Query, +) -> impl IntoResponse { + let req_id = st.rpc.next_req_id(); + let mut cmd = json!({"kind": "cliloc.table", "reqId": req_id}); + + if let Some(lang) = q.lang.as_deref().filter(|s| !s.is_empty()) { + cmd["lang"] = json!(lang); + } + + if let Some(cursor) = q.cursor.as_deref().filter(|s| !s.is_empty()) { + cmd["cursor"] = json!(cursor); + } + + respond_assets(st.rpc.call(&st.shard, cmd, &req_id).await) +} + +#[derive(Deserialize)] +struct ClilocQuery { + lang: Option, + cursor: Option, +} + // ---- websocket ---- async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { @@ -1480,6 +1553,74 @@ mod tests { assert_eq!(respond_assets(Ok(value)).0, StatusCode::BAD_REQUEST); } + /// Phase 2's codes, each of which says something a 400 does not. `NOT_FOUND` is a client that + /// does not carry the file (an operator fact, not a bug); `UNREADABLE` is a file that is there + /// and cannot be decoded, where repeating the request cannot help; `UNAVAILABLE` is the shard + /// declining for now. + #[test] + fn an_asset_error_code_picks_the_status() { + let with = |code: &str| { + respond_assets(Ok(json!({ + "kind": "assets.error", + "reqId": "r-1", + "code": code, + "reason": "…" + }))) + .0 + }; + + assert_eq!(with("DISABLED"), StatusCode::FORBIDDEN); + assert_eq!(with("NOT_FOUND"), StatusCode::NOT_FOUND); + assert_eq!(with("UNREADABLE"), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(with("UNAVAILABLE"), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(with("BAD_REQUEST"), StatusCode::BAD_REQUEST); + // An unknown code is not a reason to invent a status. + assert_eq!(with("SOMETHING_NEW"), StatusCode::BAD_REQUEST); + } + + /// The overlay and the sidecar ship separately and an operator can run a phase-1 shard against + /// a phase-2 sidecar, so the codeless refusal must keep its 403. This is the whole reason the + /// substring check survives rather than being deleted with the code it preceded. + #[test] + fn a_codeless_disabled_refusal_is_still_a_403() { + let value = json!({ + "kind": "assets.error", + "reqId": "r-9", + "reason": "asset extraction is disabled on this shard" + }); + + assert_eq!(respond_assets(Ok(value)).0, StatusCode::FORBIDDEN); + } + + /// One page of the cliloc table. The assertion that matters is the envelope: `more` and + /// `cursor` reach the caller untouched, because the sidecar does not page this — the shard + /// cuts the pages and the website drives them, and a sidecar that "helpfully" assembled them + /// would be holding the whole table in memory to do it. + #[test] + fn a_cliloc_page_is_a_200_and_keeps_its_cursor() { + let value = json!({ + "kind": "cliloc.table.ok", + "reqId": "r-2", + "lang": "enu", + "extractorVersion": 1, + "total": 67496, + "rows": [{"n": 1023721, "f": 0, "t": "quarter staff"}], + "more": true, + "cursor": "n:1023721", + "cut": "budget" + }); + + let (status, body) = respond_assets(Ok(value)); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body.0.get("more").and_then(|v| v.as_bool()), Some(true)); + assert_eq!( + body.0.get("cursor").and_then(|v| v.as_str()), + Some("n:1023721") + ); + assert_eq!(body.0.get("total").and_then(|v| v.as_i64()), Some(67496)); + } + /// A source manifest comes back whole. Worth asserting because `respond_assets` sniffs `kind` /// and a family whose success kind ends in `.ok` sits one character away from the `.error` /// suffix the generic responder matches on -- which is exactly why this plane has its own