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
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:
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user