web: correct the asset cache policy, serve the flow index, and drop a stray artifact
This commit is contained in:
parent
65b0b690f7
commit
3d716469d1
|
|
@ -14,3 +14,7 @@
|
||||||
# Vite and TypeScript caches.
|
# Vite and TypeScript caches.
|
||||||
/frontend/.vite
|
/frontend/.vite
|
||||||
/frontend/*.tsbuildinfo
|
/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
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ frontend:
|
||||||
|
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
- `GET /api/docs`, `GET /api/docs/<id>`, `GET /api/docs/search?q=`
|
- `GET /api/docs`, `GET /api/docs/<id>`, `GET /api/docs/search?q=`
|
||||||
- `GET /api/graph`, `GET /api/graph/crates`, `GET /api/graph/<flow>`
|
- `GET /api/graph`, `GET /api/graph/crates`, `GET /api/graph/flows`, `GET /api/graph/<flow>`
|
||||||
- `GET /api/api`, `GET /api/api/crates/<name>`, `GET /api/api/items/<name>`
|
- `GET /api/api`, `GET /api/api/crates/<name>`, `GET /api/api/items/<name>`
|
||||||
- `GET /api/tools`, `GET /api/tools/<id>`, `POST /api/tools/<id>/run`
|
- `GET /api/tools`, `GET /api/tools/<id>`, `POST /api/tools/<id>/run`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,20 @@ export interface GraphEdge {
|
||||||
label: string | null;
|
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. */
|
/** A graph. */
|
||||||
export interface Graph {
|
export interface Graph {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,16 @@
|
||||||
* The algorithm flow graphs.
|
* The algorithm flow graphs.
|
||||||
*
|
*
|
||||||
* Each flow describes the steps of one algorithm. Selecting a step shows
|
* 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 { getJson } from '../lib/api';
|
||||||
import { groupColor } from '../lib/format';
|
import { groupColor } from '../lib/format';
|
||||||
import { language, pick, tr } from '../lib/i18n';
|
import { language, pick, tr } from '../lib/i18n';
|
||||||
import GraphView from '../lib/GraphView.svelte';
|
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 {
|
interface Props {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -35,11 +23,12 @@
|
||||||
let lang = $derived($language);
|
let lang = $derived($language);
|
||||||
|
|
||||||
let flows = $state<Record<string, Graph>>({});
|
let flows = $state<Record<string, Graph>>({});
|
||||||
|
let index = $state<FlowIndex['flows']>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let selected = $state<string | null>(null);
|
let selected = $state<string | null>(null);
|
||||||
|
|
||||||
/** The identifier currently shown. */
|
/** The identifier currently shown. */
|
||||||
const active = $derived(id ?? FLOWS[0].id);
|
const active = $derived(id ?? index[0]?.id ?? '');
|
||||||
/** The graph currently shown. */
|
/** The graph currently shown. */
|
||||||
const graph = $derived(flows[active] ?? null);
|
const graph = $derived(flows[active] ?? null);
|
||||||
|
|
||||||
|
|
@ -48,11 +37,14 @@
|
||||||
graph && selected ? (graph.nodes.find((node) => node.id === selected) ?? null) : null,
|
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() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
for (const flow of FLOWS) {
|
if (index.length === 0) {
|
||||||
|
index = (await getJson<FlowIndex>('/api/graph/flows')).flows;
|
||||||
|
}
|
||||||
|
for (const flow of index) {
|
||||||
if (flows[flow.id]) continue;
|
if (flows[flow.id]) continue;
|
||||||
flows[flow.id] = await getJson<Graph>(`/api/graph/${flow.id}`);
|
flows[flow.id] = await getJson<Graph>(`/api/graph/${flow.id}`);
|
||||||
}
|
}
|
||||||
|
|
@ -84,12 +76,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="graph-controls">
|
<div class="graph-controls">
|
||||||
{#each FLOWS as flow}
|
{#each index as flow}
|
||||||
<a
|
<a
|
||||||
href={`#/flows/${flow.id}`}
|
href={`#/flows/${flow.id}`}
|
||||||
class="badge"
|
class="badge"
|
||||||
style={active === flow.id ? 'border-color: var(--accent); color: var(--accent);' : ''}
|
style={active === flow.id ? 'border-color: var(--accent); color: var(--accent);' : ''}
|
||||||
>{pick(flow.label, lang)}</a>
|
>{pick(flow.title, lang)}</a>
|
||||||
{/each}
|
{/each}
|
||||||
{#if loading && !graph}
|
{#if loading && !graph}
|
||||||
<span class="badge"><span class="spinner"></span> {tr('common.loading', lang)}</span>
|
<span class="badge"><span class="spinner"></span> {tr('common.loading', lang)}</span>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ pub struct Router {
|
||||||
search: crate::docs::SearchIndex,
|
search: crate::docs::SearchIndex,
|
||||||
/// Pre-encoded concept graph.
|
/// Pre-encoded concept graph.
|
||||||
concept_graph: String,
|
concept_graph: String,
|
||||||
|
/// Pre-encoded list of the flow graphs.
|
||||||
|
flow_index: String,
|
||||||
/// Pre-encoded crate structure graph.
|
/// Pre-encoded crate structure graph.
|
||||||
crate_graph: String,
|
crate_graph: String,
|
||||||
/// Pre-encoded tool registry.
|
/// Pre-encoded tool registry.
|
||||||
|
|
@ -48,6 +50,7 @@ impl Router {
|
||||||
let docs_index = json::to_string(&document_index(&state));
|
let docs_index = json::to_string(&document_index(&state));
|
||||||
let search = state.docs.search_index();
|
let search = state.docs.search_index();
|
||||||
let concept_graph = json::to_string(&graph_json(&state.graph.concepts));
|
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 crate_graph = json::to_string(&graph_json(&crate::graph::crate_structure(&state.api)));
|
||||||
let tool_registry = json::to_string(&tools::registry_json(&state.tools));
|
let tool_registry = json::to_string(&tools::registry_json(&state.tools));
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -55,6 +58,7 @@ impl Router {
|
||||||
docs_index,
|
docs_index,
|
||||||
search,
|
search,
|
||||||
concept_graph,
|
concept_graph,
|
||||||
|
flow_index,
|
||||||
crate_graph,
|
crate_graph,
|
||||||
tool_registry,
|
tool_registry,
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +79,9 @@ impl Handler for Router {
|
||||||
(Method::Get | Method::Head, "/api/graph") => {
|
(Method::Get | Method::Head, "/api/graph") => {
|
||||||
Response::json(self.concept_graph.clone()).with_cache_control("no-cache")
|
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") => {
|
(Method::Get | Method::Head, "/api/graph/crates") => {
|
||||||
Response::json(self.crate_graph.clone()).with_cache_control("no-cache")
|
Response::json(self.crate_graph.clone()).with_cache_control("no-cache")
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +211,9 @@ impl Router {
|
||||||
/// Returns one flow graph by identifier.
|
/// Returns one flow graph by identifier.
|
||||||
fn flow(&self, path: &str) -> Response {
|
fn flow(&self, path: &str) -> Response {
|
||||||
let id = path.trim_start_matches("/api/graph/");
|
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" {
|
if id == "crates" {
|
||||||
return Response::json(self.crate_graph.clone()).with_cache_control("no-cache");
|
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 !relative.is_empty() && !relative.ends_with('/') {
|
||||||
if let Some(bytes) = embedded::get(relative) {
|
if let Some(bytes) = embedded::get(relative) {
|
||||||
return Response::new(Status::Ok, embedded::content_type(relative), bytes.to_vec())
|
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
|
/// The build writes stable file names rather than content hashed ones, so a
|
||||||
/// immutable. The entry point is not, because it names the hashed files.
|
/// browser must revalidate after a deployment. A year long immutable policy
|
||||||
fn cache_policy(path: &str) -> &'static str {
|
/// would be correct only for hashed names, and would pin a reader to the
|
||||||
if path.starts_with("assets/") || path.contains('.') && path.starts_with("static/") {
|
/// bundle that was current when they first loaded the page.
|
||||||
"public, max-age=31536000, immutable"
|
const CACHE_POLICY: &str = "no-cache";
|
||||||
} else {
|
|
||||||
"no-cache"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the document listing payload.
|
/// Builds the document listing payload.
|
||||||
fn document_index(state: &AppState) -> Value {
|
fn document_index(state: &AppState) -> Value {
|
||||||
|
|
@ -515,6 +521,27 @@ fn optional_bilingual(translation: &Option<String>, 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<Value> = 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.
|
/// Encodes a graph as JSON.
|
||||||
///
|
///
|
||||||
/// A display label and a summary are carried in both languages when the
|
/// A display label and a summary are carried in both languages when the
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,44 @@ fn tool_result_labels_are_bilingual() {
|
||||||
assert!(body.contains("\"en\":\"Semi-major axis\""), "{body}");
|
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]
|
#[test]
|
||||||
fn graph_labels_are_bilingual() {
|
fn graph_labels_are_bilingual() {
|
||||||
let service = Service::start();
|
let service = Service::start();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue