Server: web client under /app/ (D-132), analytics E-34 trends and E-35 forecast (D-133)
Build Kvitt images / build (push) Failing after 1m20s

Part A (D-132): KVITT_WEB_DIR (default /app) served under /app/ by a small handler: /app redirects to
/app/, app routes fall back to index.html, a missing assets/* file is 404, assets cached immutable,
index.html no-store, the app's own CSP plus nosniff, DENY and no-referrer. Unsafe paths never reach the
file system. /app paths count against the unauthenticated per-IP limit. Without the directory every /app
path stays 404 not_found. server/Dockerfile gains a Node 24 LTS stage (pinned by digest) building web/,
passed through the Rust stage so npm and rustc never run at once on the Pi 3; the build context is the
repository root with an allowlist .dockerignore (server/.dockerignore removed); workflow, build-images.sh
and infra/dev/build.sh follow.

Part B (D-133): GET /analytics/trends (E-34) and GET /analytics/forecast (E-35), authenticated, read-only,
SQL month buckets; rules (half-up average, trailing 3-month moving average, percent against the rounded
average, template occurrence per month) in kvitt-domain::analytics. OpenAPI, examples, contracts-check
and tests updated (35 operations).

Cargo.lock: rustls-webpki 0.103.16 and webpki-root-certs 1.0.10 (RUSTSEC-2026-0382, -0383) so that
cargo audit passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 20:52:46 +02:00
parent 7bddc50a53
commit 50a0d6792a
32 changed files with 2247 additions and 48 deletions
+9
View File
@@ -19,6 +19,8 @@ pub const DEFAULT_FCM_PROJECT_ID: &str = "kvitt-1337";
pub const DEFAULT_TRUSTED_PROXY: &str = NO_TRUSTED_PROXY;
/// The `KVITT_TRUSTED_PROXY` value that means no proxy (compared ignoring case and surrounding white space).
pub const NO_TRUSTED_PROXY: &str = "none";
/// Default of `KVITT_WEB_DIR`: the web client's files in the image (D-132).
pub const DEFAULT_WEB_DIR: &str = "/app";
/// Default of `RUST_LOG`.
pub const DEFAULT_LOG_FILTER: &str = "info";
@@ -45,6 +47,9 @@ pub struct Config {
/// `KVITT_TRUSTED_PROXY`: host name whose resolved address may set `X-Forwarded-For`. Resolved lazily by the
/// rate limiter; [`NO_TRUSTED_PROXY`] (the default) and an unresolvable name mean "no proxy".
pub trusted_proxy: String,
/// `KVITT_WEB_DIR`: the built web client, served under `/app/` (`api::web`); a missing directory means no web
/// client (every `/app` path is 404).
pub web_dir: PathBuf,
}
impl Config {
@@ -79,6 +84,7 @@ impl Config {
fcm_credentials: PathBuf::from(get("KVITT_FCM_CREDENTIALS", DEFAULT_FCM_CREDENTIALS)),
fcm_project_id: get("KVITT_FCM_PROJECT_ID", DEFAULT_FCM_PROJECT_ID),
trusted_proxy: get("KVITT_TRUSTED_PROXY", DEFAULT_TRUSTED_PROXY),
web_dir: PathBuf::from(get("KVITT_WEB_DIR", DEFAULT_WEB_DIR)),
})
}
@@ -121,6 +127,7 @@ mod tests {
);
assert_eq!(config.fcm_project_id, "kvitt-1337");
assert_eq!(config.trusted_proxy, "none");
assert_eq!(config.web_dir, PathBuf::from("/app"));
assert_eq!(config.database_path(), PathBuf::from("/data/kvitt.sqlite"));
}
@@ -131,6 +138,7 @@ mod tests {
"KVITT_BIND" => Some("127.0.0.1:9000".into()),
"KVITT_PUBLIC_URL" => Some("http://192.168.1.10:8080/".into()),
"KVITT_TRUSTED_PROXY" => Some("192.168.1.20".into()),
"KVITT_WEB_DIR" => Some("/srv/kvitt-web".into()),
_ => None,
})
.unwrap();
@@ -141,6 +149,7 @@ mod tests {
assert_eq!(config.bind, "127.0.0.1:9000".parse().unwrap());
assert_eq!(config.public_url, "http://192.168.1.10:8080");
assert_eq!(config.trusted_proxy, "192.168.1.20");
assert_eq!(config.web_dir, PathBuf::from("/srv/kvitt-web"));
}
#[test]