Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
1use axum::{
2 extract::State,
3 http::{header, HeaderMap, StatusCode},
4 response::{IntoResponse, Response},
5 routing::get,
6 Router,
7};
8
9use crate::AppState;
10
11pub fn router() -> Router<AppState> {
12 Router::new()
13 .route("/favicon.ico", get(favicon))
14 .route("/robots.txt", get(robots))
15 .route("/sitemap.xml", get(sitemap))
16}
17
18/// The Paper Ledger mark: a rising figures line over the accountant's double
19/// underline, ink-on-paper. Matches the topbar brand in `base.html`.
20async fn favicon() -> Response {
21 let mut h = HeaderMap::new();
22 h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());
23 let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
24<rect width="64" height="64" rx="14" fill="#211f1a"/>
25<polyline points="14,38 26,28 37,33 52,16" fill="none" stroke="#f0ece1" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
26<circle cx="52" cy="16" r="4" fill="#f0ece1"/>
27<line x1="14" y1="49" x2="50" y2="49" stroke="#f0ece1" stroke-width="4.2" stroke-linecap="round"/>
28<line x1="14" y1="56" x2="50" y2="56" stroke="#f0ece1" stroke-width="4.2" stroke-linecap="round"/>
29</svg>"##;
30 (StatusCode::OK, h, svg).into_response()
31}
32
33async fn robots() -> Response {
34 let mut h = HeaderMap::new();
35 h.insert(header::CONTENT_TYPE, "text/plain".parse().unwrap());
36 // Keep crawlers off the JSON API and the SSE stream: both are
37 // valueless to index and /stream holds a connection open per hit.
38 (
39 StatusCode::OK,
40 h,
41 "User-agent: *\nAllow: /\nDisallow: /api/\nDisallow: /stream\n",
42 )
43 .into_response()
44}
45
46async fn sitemap(State(state): State<AppState>) -> Response {
47 let mut h = HeaderMap::new();
48 h.insert(header::CONTENT_TYPE, "application/xml".parse().unwrap());
49 let base = if state.config.base_url.is_empty() {
50 "/".to_string()
51 } else {
52 format!("{}/", state.config.base_url.trim_end_matches('/'))
53 };
54 let now = chrono::Utc::now().format("%Y-%m-%d");
55
56 // Static pages plus one entry per known symbol.
57 let tickers: Vec<String> = sqlx::query_scalar("SELECT ticker FROM symbols ORDER BY ticker")
58 .fetch_all(&state.pool)
59 .await
60 .unwrap_or_default();
61
62 let mut urls = String::new();
63 for page in ["", "search"] {
64 urls.push_str(&format!(
65 " <url><loc>{base}{page}</loc><lastmod>{now}</lastmod></url>\n"
66 ));
67 }
68 for t in tickers {
69 let enc = urlencoding::encode(&t);
70 urls.push_str(&format!(
71 " <url><loc>{base}s/{enc}</loc><lastmod>{now}</lastmod></url>\n"
72 ));
73 }
74
75 let body = format!(
76 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
77 <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n{urls}</urlset>\n"
78 );
79 (StatusCode::OK, h, body).into_response()
80}