diff --git a/.gitignore b/.gitignore index 92e2083..d09ad8e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ # Vite and TypeScript caches. /frontend/.vite /frontend/*.tsbuildinfo + +# Session exports dropped into the project directory by tooling that runs +# alongside it. They are large and unrelated to the service. +apsis-book.html diff --git a/README.md b/README.md index 2419bb3..5cdaca6 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ frontend: - `GET /health` - `GET /api/docs`, `GET /api/docs/`, `GET /api/docs/search?q=` -- `GET /api/graph`, `GET /api/graph/crates`, `GET /api/graph/` +- `GET /api/graph`, `GET /api/graph/crates`, `GET /api/graph/flows`, `GET /api/graph/` - `GET /api/api`, `GET /api/api/crates/`, `GET /api/api/items/` - `GET /api/tools`, `GET /api/tools/`, `POST /api/tools//run` diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 28e1e06..7e6d43f 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -96,6 +96,20 @@ export interface GraphEdge { label: string | null; } +/** An entry of the flow index. */ +export interface FlowEntry { + id: string; + title: Bilingual; + summary: Bilingual; + nodes: number; + edges: number; +} + +/** The flow index. */ +export interface FlowIndex { + flows: FlowEntry[]; +} + /** A graph. */ export interface Graph { id: string; diff --git a/frontend/src/pages/Flows.svelte b/frontend/src/pages/Flows.svelte index 6061c71..7e640d1 100644 --- a/frontend/src/pages/Flows.svelte +++ b/frontend/src/pages/Flows.svelte @@ -3,28 +3,16 @@ * The algorithm flow graphs. * * Each flow describes the steps of one algorithm. Selecting a step shows - * the interface item that implements it. + * the interface item that implements it. The list of flows comes from the + * backend, so adding a flow to the content is enough for it to appear + * here. */ - import type { Graph, GraphNode } from '../lib/types'; + import type { FlowIndex, Graph, GraphNode } from '../lib/types'; import { getJson } from '../lib/api'; import { groupColor } from '../lib/format'; import { language, pick, tr } from '../lib/i18n'; import GraphView from '../lib/GraphView.svelte'; - /** The flow identifiers, in display order. */ - const FLOWS: { id: string; label: { zh: string; en: string } }[] = [ - { id: 'sgp4-propagation', label: { zh: 'SGP4 传播', en: 'SGP4 propagation' } }, - { id: 'tle-parsing', label: { zh: '两行元素解析', en: 'Two-line element parsing' } }, - { - id: 'time-scale-conversion', - label: { zh: '时间尺度转换', en: 'Time scale conversion' }, - }, - { - id: 'frame-rotation', - label: { zh: '参考系旋转与大地坐标转换', en: 'Frame rotation and geodetic conversion' }, - }, - ]; - interface Props { id: string | null; } @@ -35,11 +23,12 @@ let lang = $derived($language); let flows = $state>({}); + let index = $state([]); let loading = $state(false); let selected = $state(null); /** The identifier currently shown. */ - const active = $derived(id ?? FLOWS[0].id); + const active = $derived(id ?? index[0]?.id ?? ''); /** The graph currently shown. */ const graph = $derived(flows[active] ?? null); @@ -48,11 +37,14 @@ graph && selected ? (graph.nodes.find((node) => node.id === selected) ?? null) : null, ); - /** Loads the flow graphs that have not been fetched yet. */ + /** Loads the flow index and any flow graph that has not been fetched. */ async function load() { loading = true; try { - for (const flow of FLOWS) { + if (index.length === 0) { + index = (await getJson('/api/graph/flows')).flows; + } + for (const flow of index) { if (flows[flow.id]) continue; flows[flow.id] = await getJson(`/api/graph/${flow.id}`); } @@ -84,12 +76,12 @@
- {#each FLOWS as flow} + {#each index as flow} {pick(flow.label, lang)} + >{pick(flow.title, lang)} {/each} {#if loading && !graph} {tr('common.loading', lang)} diff --git a/src/api/mod.rs b/src/api/mod.rs index edc15a4..8477bb0 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -36,6 +36,8 @@ pub struct Router { search: crate::docs::SearchIndex, /// Pre-encoded concept graph. concept_graph: String, + /// Pre-encoded list of the flow graphs. + flow_index: String, /// Pre-encoded crate structure graph. crate_graph: String, /// Pre-encoded tool registry. @@ -48,6 +50,7 @@ impl Router { let docs_index = json::to_string(&document_index(&state)); let search = state.docs.search_index(); let concept_graph = json::to_string(&graph_json(&state.graph.concepts)); + let flow_index = json::to_string(&flow_list_json(&state.graph.flows)); let crate_graph = json::to_string(&graph_json(&crate::graph::crate_structure(&state.api))); let tool_registry = json::to_string(&tools::registry_json(&state.tools)); Self { @@ -55,6 +58,7 @@ impl Router { docs_index, search, concept_graph, + flow_index, crate_graph, tool_registry, } @@ -75,6 +79,9 @@ impl Handler for Router { (Method::Get | Method::Head, "/api/graph") => { Response::json(self.concept_graph.clone()).with_cache_control("no-cache") } + (Method::Get | Method::Head, "/api/graph/flows") => { + Response::json(self.flow_index.clone()).with_cache_control("no-cache") + } (Method::Get | Method::Head, "/api/graph/crates") => { Response::json(self.crate_graph.clone()).with_cache_control("no-cache") } @@ -204,6 +211,9 @@ impl Router { /// Returns one flow graph by identifier. fn flow(&self, path: &str) -> Response { let id = path.trim_start_matches("/api/graph/"); + if id == "flows" { + return Response::json(self.flow_index.clone()).with_cache_control("no-cache"); + } if id == "crates" { return Response::json(self.crate_graph.clone()).with_cache_control("no-cache"); } @@ -376,7 +386,7 @@ impl Router { if !relative.is_empty() && !relative.ends_with('/') { if let Some(bytes) = embedded::get(relative) { return Response::new(Status::Ok, embedded::content_type(relative), bytes.to_vec()) - .with_cache_control(cache_policy(relative)); + .with_cache_control(CACHE_POLICY); } } @@ -390,17 +400,13 @@ impl Router { } } -/// Returns the cache policy for an asset. +/// The cache policy for every asset. /// -/// The build output uses content hashed file names, so those files are -/// immutable. The entry point is not, because it names the hashed files. -fn cache_policy(path: &str) -> &'static str { - if path.starts_with("assets/") || path.contains('.') && path.starts_with("static/") { - "public, max-age=31536000, immutable" - } else { - "no-cache" - } -} +/// The build writes stable file names rather than content hashed ones, so a +/// browser must revalidate after a deployment. A year long immutable policy +/// would be correct only for hashed names, and would pin a reader to the +/// bundle that was current when they first loaded the page. +const CACHE_POLICY: &str = "no-cache"; /// Builds the document listing payload. fn document_index(state: &AppState) -> Value { @@ -515,6 +521,27 @@ fn optional_bilingual(translation: &Option, fallback: &str) -> Value { } } +/// Encodes the list of flow graphs. +/// +/// The list is part of the payload so that the frontend does not carry its +/// own copy of the flow identifiers; adding a flow to the content is enough +/// for it to appear in the navigation. +fn flow_list_json(flows: &[Graph]) -> Value { + let items: Vec = flows + .iter() + .map(|graph| { + json_object! { + "id" => graph.id.clone(), + "title" => optional_bilingual(&graph.title_zh, &graph.title), + "summary" => optional_bilingual(&graph.summary_zh, &graph.summary), + "nodes" => graph.nodes.len(), + "edges" => graph.edges.len(), + } + }) + .collect(); + json_object! { "flows" => items } +} + /// Encodes a graph as JSON. /// /// A display label and a summary are carried in both languages when the diff --git a/tests/service.rs b/tests/service.rs index b688020..bf35448 100644 --- a/tests/service.rs +++ b/tests/service.rs @@ -357,6 +357,44 @@ fn tool_result_labels_are_bilingual() { assert!(body.contains("\"en\":\"Semi-major axis\""), "{body}"); } +#[test] +fn the_flow_index_lists_every_flow() { + let service = Service::start(); + wait_ready(&service); + let index = service.get("/api/graph/flows"); + let body = body(&index); + assert!(body.contains("\"flows\""), "{body}"); + assert!(body.contains("sgp4-propagation"), "{body}"); + assert!(body.contains("tle-parsing"), "{body}"); + // The index carries a bilingual title, so the frontend needs no copy of + // the flow names. + assert!(body.contains("\"title\""), "{body}"); + assert!(body.contains("\"zh\""), "{body}"); +} + +#[test] +fn static_assets_are_revalidated() { + let service = Service::start(); + wait_ready(&service); + // The build writes stable file names, so an immutable policy would pin a + // reader to the bundle current at first load. + let asset = service.get("/assets/app.js"); + assert!(asset.contains("no-cache"), "{asset}"); + assert!(!asset.contains("immutable"), "{asset}"); +} + +#[test] +fn a_traversal_attempt_does_not_reach_the_filesystem() { + let service = Service::start(); + wait_ready(&service); + let response = service.get("/../../etc/passwd"); + let body = body(&response); + assert!( + !body.contains("root:x:"), + "a traversal must not read a file" + ); +} + #[test] fn graph_labels_are_bilingual() { let service = Service::start();