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
@@ -0,0 +1,526 @@
//! E-34 spending trends and E-35 forecast (D-133, SPEC §14.5.7): read-only, computed per request from the
//! non-deleted expenses. Settlements never count as spend (AC-14.6); archived accounts and categories count
//! (AC-14.9); `measure` is `household` or `my_share` (AC-14.4).
//!
//! Months are calendar months of the stored `expense_date` (a plain `YYYY-MM-DD`, no time zone), and "today" is the
//! calendar date in Europe/Stockholm. SQLite sums per group and month (`substr(expense_date, 1, 7)`), so no request
//! walks the expenses in Rust; the averages and percentages then come from `kvitt_domain` (§14.5.7).
use std::collections::{BTreeMap, HashMap};
use axum::extract::{RawQuery, State};
use chrono::{Datelike, NaiveDate};
use kvitt_domain::{MOVING_AVERAGE_WINDOW, Period};
use sqlx::SqliteConnection;
use uuid::Uuid;
use crate::AppState;
use crate::api::{JsonBody, recurring, rows};
use crate::auth::AuthDevice;
use crate::error::AppError;
use crate::openapi::responses::BadRequest;
use crate::time::stockholm_today;
use crate::wire::{
AccountForecast, AnalyticsMeasure, AnalyticsMeasureParamSchema, AnalyticsMonthsSchema,
CategoryTrend, ForecastMonth, ForecastResponse, Trend, TrendPoint, TrendsResponse,
};
/// Default of E-34's `months`.
pub const DEFAULT_MONTHS: i64 = 6;
/// Largest E-34 `months`.
pub const MAX_MONTHS: i64 = 24;
/// Months E-35 forecasts: the two after the current one.
pub const FORECAST_MONTHS: usize = 2;
/// Whole months the E-35 baseline averages over: the window of D-128's moving average, shared (D-133).
pub const BASELINE_MONTHS: i64 = MOVING_AVERAGE_WINDOW as i64;
/// The query parameters of E-34 and E-35. Unknown keys are ignored (E-35 has no `months`); the last occurrence of a
/// key wins.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AnalyticsQuery {
measure: AnalyticsMeasure,
months: i64,
}
fn parse_query(query: Option<&str>, with_months: bool) -> Result<AnalyticsQuery, AppError> {
let mut parsed = AnalyticsQuery {
measure: AnalyticsMeasure::Household,
months: DEFAULT_MONTHS,
};
for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
match key {
"measure" => {
parsed.measure = match value {
"household" => AnalyticsMeasure::Household,
"my_share" => AnalyticsMeasure::MyShare,
_ => {
return Err(AppError::invalid_request(
"measure must be household or my_share",
)
.with_field("measure"));
}
};
}
"months" if with_months => {
parsed.months = value
.parse::<i64>()
.ok()
.filter(|m| (1..=MAX_MONTHS).contains(m))
.ok_or_else(|| {
AppError::invalid_request(format!(
"months must be an integer from 1 to {MAX_MONTHS}"
))
.with_field("months")
})?;
}
_ => {}
}
}
Ok(parsed)
}
/// "Today" in Europe/Stockholm and its month.
struct Calendar {
today: NaiveDate,
current: Period,
}
impl Calendar {
fn of(state: &AppState) -> Result<Self, AppError> {
let today = stockholm_today(state.clock.now());
let current = Period::new(
u16::try_from(today.year()).map_err(AppError::internal)?,
u8::try_from(today.month()).map_err(AppError::internal)?,
)
.ok_or_else(|| AppError::internal("today is outside 0001 to 9999"))?;
Ok(Self { today, current })
}
fn today_text(&self) -> String {
self.today.format("%Y-%m-%d").to_string()
}
/// The `count` whole months before the current one, oldest first.
fn whole_months(&self, count: i64) -> Result<Vec<Period>, AppError> {
let mut months = Vec::new();
let mut month = self.current;
for _ in 0..count {
month = month
.previous()
.ok_or_else(|| AppError::internal("month before 0001-01"))?;
months.push(month);
}
months.reverse();
Ok(months)
}
/// The `count` months after the current one, in order.
fn next_months(&self, count: usize) -> Result<Vec<Period>, AppError> {
let mut months = Vec::new();
let mut month = self.current;
for _ in 0..count {
month = month
.next()
.ok_or_else(|| AppError::internal("month after 9999-12"))?;
months.push(month);
}
Ok(months)
}
}
/// The first day of `month`, `YYYY-MM-01`.
fn first_day(month: Period) -> String {
format!("{month}-01")
}
/// One row of [`monthly_sums`]: the group id, the month, whether the expenses were generated from a recurring
/// template, and their sum for the measure.
type MonthlySum = (String, String, bool, i64);
/// What [`monthly_sums`] groups by.
#[derive(Debug, Clone, Copy)]
enum GroupBy {
Category,
Account,
}
/// The statement of [`monthly_sums`] for one group column, value and join (constants only).
macro_rules! monthly_sums_sql {
($group:literal, $value:literal, $join:literal) => {
concat!(
"SELECT ",
$group,
", substr(e.expense_date, 1, 7) AS month, ",
"e.recurring_template_id IS NOT NULL AS generated, SUM(",
$value,
") FROM expenses e ",
$join,
" WHERE e.deleted_at IS NULL AND e.expense_date >= ? AND e.expense_date <= ? ",
"GROUP BY ",
$group,
", month, generated"
)
};
}
/// Sums of the non-deleted expenses dated `from` to `to` (inclusive, `YYYY-MM-DD`), per group, month and generated
/// flag. `user` set: that user's shares (`my_share`, the join binds the user before the dates); `None`: the full
/// amounts (`household`).
async fn monthly_sums(
conn: &mut SqliteConnection,
group_by: GroupBy,
user: Option<Uuid>,
from: &str,
to: &str,
) -> Result<Vec<MonthlySum>, AppError> {
let sql: &'static str = match (group_by, user.is_some()) {
(GroupBy::Category, false) => monthly_sums_sql!("e.category_id", "e.amount_ore", ""),
(GroupBy::Account, false) => monthly_sums_sql!("e.account_id", "e.amount_ore", ""),
(GroupBy::Category, true) => monthly_sums_sql!(
"e.category_id",
"s.share_ore",
"JOIN expense_splits s ON s.expense_id = e.id AND s.user_id = ?"
),
(GroupBy::Account, true) => monthly_sums_sql!(
"e.account_id",
"s.share_ore",
"JOIN expense_splits s ON s.expense_id = e.id AND s.user_id = ?"
),
};
let mut query = sqlx::query_as::<_, MonthlySum>(sql);
if let Some(user) = user {
query = query.bind(user.to_string());
}
Ok(query.bind(from).bind(to).fetch_all(&mut *conn).await?)
}
/// The user whose shares `measure` adds up, if any.
fn measured_user(measure: AnalyticsMeasure, device: &AuthDevice) -> Option<Uuid> {
match measure {
AnalyticsMeasure::Household => None,
AnalyticsMeasure::MyShare => Some(device.user_id),
}
}
/// A [`Trend`] from the amounts of the whole months (oldest first) and the current month so far.
fn trend(months: &[Period], amounts: &[i64], current_month_ore: i64) -> Trend {
let average_ore = kvitt_domain::average(amounts);
let last_month_ore = amounts.last().copied().unwrap_or(0);
let moving = kvitt_domain::moving_average(amounts, MOVING_AVERAGE_WINDOW);
Trend {
total_ore: amounts.iter().sum(),
average_ore,
last_month_ore,
last_month_vs_average_pct: kvitt_domain::percent_vs_average(last_month_ore, average_ore),
current_month_ore,
points: months
.iter()
.zip(amounts)
.zip(moving)
.map(|((month, amount), moving_average_ore)| TrendPoint {
month: month.to_string(),
amount_ore: *amount,
moving_average_ore,
})
.collect(),
}
}
/// Amounts per whole month and the current month's, for one group.
#[derive(Debug, Clone, Default)]
struct GroupAmounts {
whole: Vec<i64>,
current: i64,
}
#[utoipa::path(
get,
path = "/analytics/trends",
operation_id = "getAnalyticsTrends",
summary = "E-34 Spending trends and category averages",
description = "D-133, SPEC §14.5.7. The last `months` whole calendar months before the current one (Europe/Stockholm), \
for the household and per category: each month's spend, the trailing 3-month moving average (the first months average \
what exists, D-128), the total, the average per month (half-up to öre, empty months count), the last whole month \
against the average in whole percent (half-up; null when the average is 0), and the current month so far. Non-deleted \
expenses only; settlements never count; archived and deleted categories count.",
params(
("months" = Option<AnalyticsMonthsSchema>, Query, description = "Whole months, 1 to 24 (`invalid_request` otherwise)."),
("measure" = Option<AnalyticsMeasureParamSchema>, Query, description = "`household` or `my_share` (`invalid_request` otherwise), AC-14.4."),
),
responses(
(status = 200, description = "The trends.", body = TrendsResponse, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
)
)]
pub async fn get_trends(
device: AuthDevice,
State(state): State<AppState>,
RawQuery(query): RawQuery,
) -> Result<JsonBody<TrendsResponse>, AppError> {
let query = parse_query(query.as_deref(), true)?;
let calendar = Calendar::of(&state)?;
let months = calendar.whole_months(query.months)?;
let index: HashMap<String, usize> = months
.iter()
.enumerate()
.map(|(i, m)| (m.to_string(), i))
.collect();
let current_key = calendar.current.to_string();
let from = months
.first()
.map_or_else(|| first_day(calendar.current), |m| first_day(*m));
let mut conn = state.db.acquire().await?;
let sums = monthly_sums(
&mut conn,
GroupBy::Category,
measured_user(query.measure, &device),
&from,
&calendar.today_text(),
)
.await?;
drop(conn);
let empty = || GroupAmounts {
whole: vec![0; months.len()],
current: 0,
};
let mut household = empty();
let mut categories: BTreeMap<String, GroupAmounts> = BTreeMap::new();
for (category_id, month, _generated, amount) in sums {
let category = categories.entry(category_id).or_insert_with(empty);
if let Some(&i) = index.get(&month) {
category.whole[i] += amount;
household.whole[i] += amount;
} else if month == current_key {
category.current += amount;
household.current += amount;
}
}
let mut categories: Vec<CategoryTrend> = categories
.into_iter()
.filter(|(_, a)| a.current != 0 || a.whole.iter().any(|v| *v != 0))
.map(|(id, a)| {
Ok(CategoryTrend {
category_id: rows::uuid(&id)?,
trend: trend(&months, &a.whole, a.current),
})
})
.collect::<Result<_, AppError>>()?;
categories.sort_by(|a, b| {
b.trend
.total_ore
.cmp(&a.trend.total_ore)
.then_with(|| a.category_id.cmp(&b.category_id))
});
Ok(JsonBody(TrendsResponse {
measure: query.measure,
today: calendar.today_text(),
current_month: current_key,
months: query.months,
household: trend(&months, &household.whole, household.current),
categories,
}))
}
/// Per account: the recurring amount due in each forecast month, for the measure.
async fn recurring_per_account(
conn: &mut SqliteConnection,
user: Option<Uuid>,
forecast: &[Period],
) -> Result<HashMap<Uuid, Vec<i64>>, AppError> {
let ids: Vec<String> = sqlx::query_scalar(
"SELECT id FROM recurring_templates WHERE deleted_at IS NULL AND paused = 0 ORDER BY id",
)
.fetch_all(&mut *conn)
.await?;
let users = match user {
Some(_) => rows::household(conn).await?,
None => Vec::new(),
};
let mut due: HashMap<Uuid, Vec<i64>> = HashMap::new();
for id in ids {
let Some(template) = rows::template(conn, rows::uuid(&id)?).await? else {
continue;
};
let amount = match user {
None => template.amount_ore,
// The shares a generation would write now (mode default: the account's current default ratio).
Some(user) => recurring::resolve_splits(conn, &template, &users)
.await?
.iter()
.find(|s| s.user_id == user)
.map_or(0, |s| s.share_ore),
};
let amounts = due
.entry(template.account_id)
.or_insert_with(|| vec![0; forecast.len()]);
for (slot, month) in amounts.iter_mut().zip(forecast) {
// One expense per template and month (SPEC §9.5).
if kvitt_domain::occurs_in_period(
&template.start_period,
template.end_period.as_deref(),
template.last_generated_period.as_deref(),
*month,
)
.map_err(AppError::internal)?
{
*slot += amount;
}
}
}
Ok(due)
}
#[utoipa::path(
get,
path = "/analytics/forecast",
operation_id = "getAnalyticsForecast",
summary = "E-35 Spending forecast per account",
description = "D-133, SPEC §14.5.7. For every non-deleted account (archived included) and each of the two calendar \
months after the current one (Europe/Stockholm): `recurring_ore` = the amounts of the account's non-paused, \
non-deleted recurring templates due in that month (one per template and month, within `start_period` and \
`end_period`); `baseline_ore` = the account's spend over the last 3 whole months (the window of D-128's \
moving average), expenses generated from a recurring template excluded, divided by 3 (half-up to öre; months without spend count, so a new account has a lower \
baseline); `forecast_ore = recurring_ore + baseline_ore`. `current_month_ore` is the current month so far, \
generated expenses included. `my_share` uses the shares a generation would write today.",
params(
("measure" = Option<AnalyticsMeasureParamSchema>, Query, description = "`household` or `my_share` (`invalid_request` otherwise), AC-14.4."),
),
responses(
(status = 200, description = "The forecast.", body = ForecastResponse, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
)
)]
pub async fn get_forecast(
device: AuthDevice,
State(state): State<AppState>,
RawQuery(query): RawQuery,
) -> Result<JsonBody<ForecastResponse>, AppError> {
let query = parse_query(query.as_deref(), false)?;
let user = measured_user(query.measure, &device);
let calendar = Calendar::of(&state)?;
let baseline_months = calendar.whole_months(BASELINE_MONTHS)?;
let forecast_months = calendar.next_months(FORECAST_MONTHS)?;
let current_key = calendar.current.to_string();
let baseline_keys: Vec<String> = baseline_months.iter().map(ToString::to_string).collect();
let from = baseline_months
.first()
.map_or_else(|| first_day(calendar.current), |m| first_day(*m));
let mut conn = state.db.acquire().await?;
let accounts: Vec<String> = sqlx::query_scalar(
"SELECT id FROM accounts WHERE deleted_at IS NULL ORDER BY created_at, id",
)
.fetch_all(&mut *conn)
.await?;
let sums = monthly_sums(
&mut conn,
GroupBy::Account,
user,
&from,
&calendar.today_text(),
)
.await?;
let recurring = recurring_per_account(&mut conn, user, &forecast_months).await?;
drop(conn);
// Per account: (baseline total over the whole months, current month so far).
let mut spend: HashMap<String, (i64, i64)> = HashMap::new();
for (account_id, month, generated, amount) in sums {
let entry = spend.entry(account_id).or_default();
if month == current_key {
entry.1 += amount;
} else if !generated && baseline_keys.contains(&month) {
entry.0 += amount;
}
}
let accounts = accounts
.into_iter()
.map(|id| {
let account_id = rows::uuid(&id)?;
let (baseline_total, current_month_ore) = spend.get(&id).copied().unwrap_or((0, 0));
let baseline_ore = kvitt_domain::div_half_up(baseline_total, BASELINE_MONTHS);
let due = recurring.get(&account_id);
Ok(AccountForecast {
account_id,
current_month_ore,
months: forecast_months
.iter()
.enumerate()
.map(|(i, month)| {
let recurring_ore = due.and_then(|d| d.get(i)).copied().unwrap_or(0);
ForecastMonth {
month: month.to_string(),
recurring_ore,
baseline_ore,
forecast_ore: recurring_ore + baseline_ore,
}
})
.collect(),
})
})
.collect::<Result<_, AppError>>()?;
Ok(JsonBody(ForecastResponse {
measure: query.measure,
today: calendar.today_text(),
current_month: current_key,
accounts,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_defaults_and_values() {
let q = parse_query(None, true).unwrap();
assert_eq!(q.measure, AnalyticsMeasure::Household);
assert_eq!(q.months, 6);
let q = parse_query(Some("months=24&measure=my_share&other=1"), true).unwrap();
assert_eq!(q.measure, AnalyticsMeasure::MyShare);
assert_eq!(q.months, 24);
assert_eq!(parse_query(Some("months=1"), true).unwrap().months, 1);
// E-35 ignores `months`.
assert_eq!(parse_query(Some("months=99"), false).unwrap().months, 6);
}
#[test]
fn query_errors_name_the_parameter() {
for bad in ["months=0", "months=25", "months=x", "months=", "months=-1"] {
let e = parse_query(Some(bad), true).unwrap_err();
assert_eq!(e.field.as_deref(), Some("months"), "{bad}");
}
for bad in ["measure=mine", "measure=", "measure=Household"] {
let e = parse_query(Some(bad), true).unwrap_err();
assert_eq!(e.field.as_deref(), Some("measure"), "{bad}");
}
}
#[test]
fn trend_fields() {
let months: Vec<Period> = ["2026-07", "2026-08", "2026-09"]
.iter()
.map(|m| m.parse().unwrap())
.collect();
let t = trend(&months, &[300, 0, 600], 50);
assert_eq!(t.total_ore, 900);
assert_eq!(t.average_ore, 300);
assert_eq!(t.last_month_ore, 600);
assert_eq!(t.last_month_vs_average_pct, Some(100));
assert_eq!(t.current_month_ore, 50);
let moving: Vec<i64> = t.points.iter().map(|p| p.moving_average_ore).collect();
assert_eq!(moving, vec![300, 150, 300]);
assert_eq!(t.points[0].month, "2026-07");
assert_eq!(
trend(&months, &[0, 0, 0], 0).last_month_vs_average_pct,
None
);
}
}
@@ -3,6 +3,7 @@
//! row access and input rules the handlers, the sync protocol and the importer share.
pub mod accounts;
pub mod analytics;
pub mod apk;
pub mod categories;
pub mod devices;
@@ -18,6 +19,7 @@ pub mod rows;
pub mod setup;
pub mod sync;
pub mod validate;
pub mod web;
use axum::extract::rejection::{JsonRejection, PathRejection};
use axum::extract::{FromRequest, FromRequestParts, Request};
@@ -1,6 +1,8 @@
//! Rate limiting (SPEC §8.6, AC-2.12, §8.5), as a tower layer that `router.rs` attaches to every route.
//!
//! * Unauthenticated endpoints (E-1 to E-5 and unknown paths): at most 60 requests per minute per client IP.
//! * Unauthenticated endpoints (E-1 to E-5, the web client's static files under `/app/` and unknown paths): at most
//! 60 requests per minute per client IP. A page load of the web client is its `index.html` and a handful of hashed
//! assets, which the browser then caches for a year, so 60 per minute leaves room for reloads (D-132).
//! * APK downloads (E-6, `/apk/files/{file_name}`, D-119): at most 10 requests per minute per client IP, in a bucket
//! of their own that does not count against the 60 (security review L-3: each answer can be a 10 MB file).
//! * Authenticated endpoints: at most 600 requests per minute per device, keyed by the hash of the bearer token
@@ -62,14 +64,18 @@ pub const PROXY_RESOLVE_INTERVAL: std::time::Duration = std::time::Duration::fro
/// How often expired windows are pruned from memory.
const PRUNE_INTERVAL: Duration = Duration::minutes(10);
/// Route templates of the unauthenticated endpoints (E-1 to E-6).
const PUBLIC_ROUTES: [&str; 6] = [
/// Route templates of the unauthenticated endpoints (E-1 to E-6) and of the web client (D-132), which count against
/// the client IP even when a request carries a bearer token.
const PUBLIC_ROUTES: [&str; 9] = [
"/health",
"/setup/status",
"/invites/accept",
"/i/{code}",
"/apk/latest",
"/apk/files/{file_name}",
"/app",
"/app/",
"/app/{*path}",
];
const ACCEPT_ROUTE: &str = "/invites/accept";
/// Route template of E-6, limited by [`APK_DOWNLOAD_LIMIT`] instead of [`UNAUTHENTICATED_LIMIT`].
@@ -58,8 +58,8 @@ pub struct GeneratedExpense {
pub period: String,
}
/// The expense splits of a generated expense.
async fn resolve_splits(
/// The expense splits of a generated expense (also E-35's `my_share` forecast of a template).
pub(crate) async fn resolve_splits(
conn: &mut SqliteConnection,
template: &RecurringTemplate,
users: &[User],
+245
View File
@@ -0,0 +1,245 @@
//! The web client under `/app/` (D-132, SPEC §10.4 `KVITT_WEB_DIR`): static files of the single-page app built into
//! the image at `/app`. Not an API route: no protocol check, no token, not in `contracts/openapi.yaml`.
//!
//! * `GET /app` redirects to `/app/` (308); `/app/` and `/app/index.html` serve `index.html`.
//! * `GET /app/<path>` serves the file when `<path>` is safe (below) and names a file in the directory. A missing or
//! unsafe path falls back to `index.html` (the app routes in the browser), except under `assets/`, where a missing
//! file is 404 `not_found`: a hashed asset that is gone must fail, not load HTML.
//! * Caching: `assets/*` (content-hashed names) `public, max-age=31536000, immutable`; `index.html` `no-store` (it names
//! the current assets); other files `no-cache`.
//! * Headers: the app's own `Content-Security-Policy` ([`APP_CSP`]; the strict policy NPM sets elsewhere would block the
//! app's scripts and API calls), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy:
//! no-referrer`.
//! * Without the directory (an image built without the app, the development `cargo run`) every `/app` path is 404
//! `not_found`, as before the web client existed.
//!
//! Path safety: a path is at most [`MAX_PATH_LEN`] bytes and [`MAX_SEGMENTS`] segments, and each segment is 1 to 255
//! characters of `A-Z a-z 0-9 . _ -` that does not start with `.`. So no `..`, no hidden file, no absolute path, no
//! back-slash or percent trick reaches the file system: an unsafe path is never joined to the directory.
use std::path::Path as FsPath;
use axum::extract::{Path, State};
use axum::http::{HeaderValue, header};
use axum::response::{IntoResponse, Redirect, Response};
use crate::AppState;
use crate::error::AppError;
/// The prefix the app is served under; Vite builds it with `base: '/app/'`.
pub const APP_PREFIX: &str = "/app/";
/// The `Content-Security-Policy` of every response under `/app/`: exactly the policy NPM sets for `/app/` (D-132,
/// `$kvitt_csp` in `infra/npm/kvitt.conf`), so the development stack behaves as the Pi does.
pub const APP_CSP: &str = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; \
frame-ancestors 'none'";
/// `Cache-Control` of `assets/*`.
pub const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable";
/// `Cache-Control` of `index.html`.
pub const CACHE_INDEX: &str = "no-store";
/// `Cache-Control` of the other files (favicon, manifest).
pub const CACHE_OTHER: &str = "no-cache";
/// Longest path under `/app/` that is looked up, in bytes.
pub const MAX_PATH_LEN: usize = 512;
/// Most segments of a path under `/app/` that is looked up.
pub const MAX_SEGMENTS: usize = 8;
const INDEX: &str = "index.html";
const ASSETS: &str = "assets/";
/// Whether `path` (relative, without the `/app/` prefix) may be joined to the web directory.
pub fn is_safe_path(path: &str) -> bool {
path.len() <= MAX_PATH_LEN
&& path.split('/').count() <= MAX_SEGMENTS
&& path.split('/').all(|segment| {
(1..=255).contains(&segment.len())
&& !segment.starts_with('.')
&& segment
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
})
}
/// The media type of a served file, from its extension.
pub fn content_type(path: &str) -> &'static str {
let extension = path
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.unwrap_or_default();
match extension.as_str() {
"html" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" | "map" => "application/json; charset=utf-8",
"webmanifest" => "application/manifest+json",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"ico" => "image/x-icon",
"woff2" => "font/woff2",
"woff" => "font/woff",
"txt" => "text/plain; charset=utf-8",
"wasm" => "application/wasm",
_ => "application/octet-stream",
}
}
fn cache_control(path: &str) -> &'static str {
if path.starts_with(ASSETS) {
CACHE_IMMUTABLE
} else if path == INDEX {
CACHE_INDEX
} else {
CACHE_OTHER
}
}
/// The bytes of `dir/path` when it is a regular file; `None` when it is missing or not a file.
async fn read_file(dir: &FsPath, path: &str) -> Result<Option<Vec<u8>>, AppError> {
let full = dir.join(path);
match tokio::fs::metadata(&full).await {
Ok(meta) if meta.is_file() => {}
Ok(_) => return Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(AppError::internal(format!("cannot stat web file: {e}"))),
}
match tokio::fs::read(&full).await {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(AppError::internal(format!("cannot read web file: {e}"))),
}
}
async fn web_dir_exists(state: &AppState) -> bool {
tokio::fs::metadata(&state.config.web_dir)
.await
.is_ok_and(|m| m.is_dir())
}
fn file_response(path: &str, bytes: Vec<u8>) -> Response {
let mut response = bytes.into_response();
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(content_type(path)),
);
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_control(path)),
);
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(APP_CSP),
);
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
response
}
/// Serves `path` (relative to the web directory; empty for the index) with the fallback rules of the module docs.
async fn serve(state: &AppState, path: &str) -> Result<Response, AppError> {
if !web_dir_exists(state).await {
return Err(AppError::not_found());
}
let dir = &state.config.web_dir;
if !path.is_empty()
&& is_safe_path(path)
&& let Some(bytes) = read_file(dir, path).await?
{
return Ok(file_response(path, bytes));
}
if path.starts_with(ASSETS) {
return Err(AppError::not_found());
}
match read_file(dir, INDEX).await? {
Some(bytes) => Ok(file_response(INDEX, bytes)),
None => Err(AppError::not_found()),
}
}
/// `GET /app`: 308 to `/app/` when the app is installed.
pub async fn get_app_root(State(state): State<AppState>) -> Result<Response, AppError> {
if web_dir_exists(&state).await {
Ok(Redirect::permanent(APP_PREFIX).into_response())
} else {
Err(AppError::not_found())
}
}
/// `GET /app/`: the index.
pub async fn get_app_index(State(state): State<AppState>) -> Result<Response, AppError> {
serve(&state, "").await
}
/// `GET /app/{*path}`. A path that does not percent-decode to UTF-8 is never looked up: it gets the index.
pub async fn get_app_file(
State(state): State<AppState>,
path: Result<Path<String>, axum::extract::rejection::PathRejection>,
) -> Result<Response, AppError> {
match path {
Ok(Path(path)) => serve(&state, &path).await,
Err(_) => serve(&state, "").await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_paths() {
for ok in [
"index.html",
"assets/index-B2x_9a.js",
"favicon.svg",
"expenses/0192a003-0000-7000-8000-000000000001",
"a/b/c/d/e/f/g/h",
] {
assert!(is_safe_path(ok), "{ok}");
}
for bad in [
"",
"..",
"../kvitt.sqlite",
"assets/../../etc/passwd",
".env",
"assets/.hidden",
"/etc/passwd",
"a//b",
"a/",
"a\\b",
"a%2e%2e",
"caf\u{e9}",
"a b",
"a/b/c/d/e/f/g/h/i",
"\u{0}",
] {
assert!(!is_safe_path(bad), "{bad:?}");
}
assert!(!is_safe_path(&"a".repeat(256)));
assert!(!is_safe_path(&format!("{}/x", "a/".repeat(300))));
}
#[test]
fn content_types_and_caching() {
assert_eq!(content_type("index.html"), "text/html; charset=utf-8");
assert_eq!(
content_type("assets/a-1.JS"),
"text/javascript; charset=utf-8"
);
assert_eq!(content_type("assets/a-1.css"), "text/css; charset=utf-8");
assert_eq!(content_type("favicon.svg"), "image/svg+xml");
assert_eq!(content_type("LICENSE"), "application/octet-stream");
assert_eq!(cache_control("assets/a-1.js"), CACHE_IMMUTABLE);
assert_eq!(cache_control("index.html"), CACHE_INDEX);
assert_eq!(cache_control("favicon.svg"), CACHE_OTHER);
}
}