From 65de1782c9a7e80a52b6e7a408a464cf64e74b64 Mon Sep 17 00:00:00 2001 From: wt Date: Thu, 29 Oct 2026 18:43:28 +0700 Subject: [PATCH] initial commit --- .dockerignore | 9 + .env.example | 6 + .gitignore | 10 + AGENTS.md | 137 +++ Cargo.lock | 1079 +++++++++++++++++++++ Cargo.toml | 28 + Dockerfile | 18 + README.md | 143 +++ docker-compose.yml | 19 + docs/API.md | 151 +++ docs/ARCHITECTURE.md | 119 +++ docs/DATA_MODEL.md | 119 +++ docs/DEPLOYMENT.md | 85 ++ docs/DEVELOPMENT.md | 120 +++ docs/PRODUCT.md | 62 ++ docs/SECURITY.md | 79 ++ docs/SYNC.md | 138 +++ go.mod | 21 + go.sum | 51 + internal/auth/password.go | 61 ++ internal/auth/tokens.go | 90 ++ internal/config/config.go | 93 ++ internal/database/database.go | 80 ++ internal/httpapi/auth_handlers.go | 245 +++++ internal/httpapi/server.go | 185 ++++ internal/httpapi/server_test.go | 204 ++++ internal/httpapi/sync_handler.go | 169 ++++ internal/httpapi/web.go | 34 + internal/httpapi/web/app.js | 1058 ++++++++++++++++++++ internal/httpapi/web/icon.svg | 19 + internal/httpapi/web/index.html | 106 ++ internal/httpapi/web/manifest.webmanifest | 13 + internal/httpapi/web/service-worker.js | 22 + internal/httpapi/web/styles.css | 487 ++++++++++ src/auth.rs | 432 +++++++++ src/config.rs | 160 +++ src/database.rs | 185 ++++ src/go_json.rs | 326 +++++++ src/main.rs | 49 + src/runtime.rs | 550 +++++++++++ src/server.rs | 1015 +++++++++++++++++++ src/sync.rs | 779 +++++++++++++++ tools/compatibility.py | 408 ++++++++ 43 files changed, 9164 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DATA_MODEL.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/PRODUCT.md create mode 100644 docs/SECURITY.md create mode 100644 docs/SYNC.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/password.go create mode 100644 internal/auth/tokens.go create mode 100644 internal/config/config.go create mode 100644 internal/database/database.go create mode 100644 internal/httpapi/auth_handlers.go create mode 100644 internal/httpapi/server.go create mode 100644 internal/httpapi/server_test.go create mode 100644 internal/httpapi/sync_handler.go create mode 100644 internal/httpapi/web.go create mode 100644 internal/httpapi/web/app.js create mode 100644 internal/httpapi/web/icon.svg create mode 100644 internal/httpapi/web/index.html create mode 100644 internal/httpapi/web/manifest.webmanifest create mode 100644 internal/httpapi/web/service-worker.js create mode 100644 internal/httpapi/web/styles.css create mode 100644 src/auth.rs create mode 100644 src/config.rs create mode 100644 src/database.rs create mode 100644 src/go_json.rs create mode 100644 src/main.rs create mode 100644 src/runtime.rs create mode 100644 src/server.rs create mode 100644 src/sync.rs create mode 100644 tools/compatibility.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..310b43f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.env +data +*.db +*.db-shm +*.db-wal +balance-server +target +__pycache__ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b100fd5 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Generate with: openssl rand -base64 48 +BALANCE_JWT_SECRET=replace-with-at-least-32-random-characters +BALANCE_ALLOW_REGISTRATION=true +BALANCE_ACCESS_TTL=15m +BALANCE_REFRESH_TTL=720h +BALANCE_CORS_ORIGINS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98f779b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +data/ +dist/ +*.db +*.db-shm +*.db-wal +balance-server +balance-server.exe +target/ +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..10f627d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,137 @@ +# AGENTS.md + +## Назначение + +**BalanceServer** — серверная часть приложения Balance, используемая iOS/macOS/watchOS и Android-клиентами. + +Этот файл является operational guide для AI-агентов и разработчиков. При изменениях сначала изучай существующий код и сохраняй текущий API-контракт клиентов. + +## Быстрая карта проекта + +Production-сервер написан на Rust 1.90+. Go 1.25.0+ сохранён как +эталон совместимости API/SQLite и вариант отката. + +Основные Rust-модули: `src/main.rs`, `runtime.rs`, `config.rs`, +`database.rs`, `auth.rs`, `server.rs`, `sync.rs`, `go_json.rs`. +Веб-клиент из `internal/httpapi/web` встроен в Rust executable. + +Основные Go-файлы: + +```text +- internal/database/database.go +- internal/config/config.go +- internal/auth/tokens.go +- internal/auth/password.go +- internal/httpapi/auth_handlers.go +- internal/httpapi/server.go +- internal/httpapi/sync_handler.go +- internal/httpapi/web.go +- internal/httpapi/server_test.go +``` + +Количество Go-файлов: **9**. + +Обнаруженные технологии/интеграции: SQLite, Docker. + +## Правила изменений + +### 1. Не ломать клиентский контракт + +Сервер обслуживает несколько клиентов: +- Apple Balance; +- Android Balance. + +Изменение JSON-полей, HTTP methods, endpoint paths, auth semantics или sync semantics считается breaking change, даже если сервер компилируется. + +Перед изменением API ищи использование endpoint/field в server code и документации клиентов. + +### 2. Sync — центральный контракт + +Синхронизация должна оставаться: +- детерминированной; +- идемпотентной при повторной доставке; +- устойчивой к повторным запросам; +- корректной при нескольких устройствах одного пользователя. + +Не меняй правила cursor/sequence/update timestamps без обновления `docs/SYNC.md`. + +### 3. Аутентификация + +Никогда не: +- логируй пароль; +- логируй access/refresh token; +- возвращай секреты в ошибках; +- храни plaintext passwords. + +При изменении auth обязательно проверь: +- регистрацию; +- login; +- refresh; +- logout; +- истечение токенов; +- авторизацию sync endpoint. + +### 4. База данных + +Любое изменение schema должно иметь: +- migration strategy; +- обратную совместимость там, где её требуют старые клиенты; +- проверку индексов для user/entity/id/sequence/update timestamp. + +Не удаляй существующие поля только потому, что текущий клиент ими не пользуется. + +### 5. Конкурентность + +Сервер должен быть безопасен при одновременных запросах от нескольких устройств. + +Особое внимание: +- генерации sequence; +- выдаче cursor; +- записи изменений; +- refresh-token rotation; +- race между push и pull. + +### 6. Ошибки + +HTTP API должен возвращать стабильную структуру ошибок. Не отдавай stack traces, SQL errors или внутренние пути клиенту. + +Внутренние детали логируй только в безопасном виде. + +### 7. Тесты + +Перед PR: + +```bash +cargo fmt --check +cargo test --locked +cargo clippy --locked --all-targets -- -D warnings +cargo build --locked --release +python3 tools/compatibility.py --rust-binary target/release/balance-server +go test ./... +go vet ./... +go build ./... +``` + +Если проект использует дополнительные проверки/линтеры, запускай их по Makefile/CI. + +### 8. Документация + +При изменении: +- endpoint → обновить `docs/API.md`; +- sync → `docs/SYNC.md`; +- DB model → `docs/DATA_MODEL.md`; +- deploy/config → `docs/DEPLOYMENT.md`; +- архитектуры → `docs/ARCHITECTURE.md`. + +## Checklist + +- [ ] Не изменён API случайно. +- [ ] Auth secrets не попадают в logs/errors. +- [ ] Sync остаётся идемпотентным. +- [ ] DB migration учтена. +- [ ] Concurrent requests безопасны. +- [ ] `go test ./...` проходит. +- [ ] Rust tests, fmt, clippy и release build проходят. +- [ ] Differential HTTP/JSON и Go ↔ Rust SQLite takeover проходят. +- [ ] `go vet ./...` проходит. +- [ ] Документация обновлена. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5ca1858 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1079 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "balance-server" +version = "1.0.0" +dependencies = [ + "argon2", + "axum", + "base64", + "chrono", + "hmac", + "http-body", + "httpdate", + "hyper", + "hyper-util", + "rand", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tokio", + "tower", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4304527 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "balance-server" +version = "1.0.0" +edition = "2024" +rust-version = "1.90" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["full"] } +rusqlite = { version = "0.37", features = ["bundled"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["raw_value", "arbitrary_precision"] } +argon2 = "0.5" +base64 = "0.22" +hmac = "0.12" +sha2 = "0.10" +rand = "0.8" +httpdate = "1" +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["tokio", "service"] } +tower = { version = "0.5", features = ["util"] } +http-body = "1" + +[profile.release] +lto = true +strip = true +codegen-units = 1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..df8f1dd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM rust:1.90-bookworm AS build +WORKDIR /src +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY internal/httpapi/web ./internal/httpapi/web +RUN cargo build --locked --release + +FROM debian:bookworm-slim +# Keep the original Alpine image's numeric identity so existing volumes remain writable. +RUN groupadd --system --gid 101 balance \ + && useradd --system --uid 100 --gid balance --home-dir /nonexistent --no-log-init balance \ + && mkdir /data && chown balance:balance /data +COPY --from=build /src/target/release/balance-server /usr/local/bin/balance-server +USER balance +ENV BALANCE_ADDR=:8080 BALANCE_DB_PATH=/data/balance.db +EXPOSE 8080 +VOLUME ["/data"] +ENTRYPOINT ["balance-server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b76f77e --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# Balance Server + +Self-hosted synchronization, account and web server for the Balance iOS, macOS, watchOS and Android applications. It is a single Rust process with a bundled SQLite engine and a complete browser client, and runs on Linux, Windows and macOS. + +## Quick start with Docker + +```sh +cp .env.example .env +openssl rand -base64 48 +# Paste the generated value into BALANCE_JWT_SECRET in .env +docker compose up -d --build +curl http://localhost:8123/health +``` + +Open `http://localhost:8123` to register or sign in to the web application. Data is kept in the `balance-data` Docker volume. Back it up while the container is stopped. + +## Run without Docker + +Install Rust 1.90 or newer, then build the locked dependencies: + +```sh +cargo build --locked --release +``` + +The binary is `target/release/balance-server` (`balance-server.exe` on Windows). +SQLite is compiled into the binary; no separate SQLite service is needed. + +On Linux or macOS: + +```sh +export BALANCE_JWT_SECRET="replace-with-a-long-random-secret" +export BALANCE_DB_PATH="./data/balance.db" +./target/release/balance-server +``` + +On Windows PowerShell: + +```powershell +$env:BALANCE_JWT_SECRET = "replace-with-a-long-random-secret" +$env:BALANCE_DB_PATH = ".\data\balance.db" +.\target\release\balance-server.exe +``` + +For another platform, build on that platform or install its Rust target and C toolchain +for bundled SQLite. `cargo build --locked --release --target ` requires the +matching linker and C compiler. The retained Go implementation is a compatibility +reference and rollback option: `go build -o balance-server-go ./cmd/balance-server`. + +After starting the binary, open `http://localhost:8080` in a browser. The web assets are embedded into the executable, so there is no separate frontend build, Node.js process or static directory to deploy. + +## Web application + +The responsive web client includes: + +- registration, login, automatic session refresh and logout; +- dashboard, current balance and manual balance adjustment; +- adding, editing, deleting, searching and filtering transactions; +- analytics for 1, 3 and 12 months, spending breakdown and savings rate; +- monthly category budgets; +- custom categories with 50 icons, custom emoji and 24 colors; +- light, dark and system themes, plus RUB, EUR, USD and SEK formatting; +- automatic synchronization every 30 seconds and whenever the browser tab becomes active; +- installable PWA shell for desktop and mobile browsers. + +The browser session uses `HttpOnly`, `SameSite=Strict` cookies. Native applications continue to use Bearer access tokens and are fully backward compatible. Only display preferences are saved in browser local storage; tokens and financial records are not stored there. + +## Configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `BALANCE_ADDR` | `:8080` | Listen address | +| `BALANCE_DB_PATH` | `./data/balance.db` | SQLite file path | +| `BALANCE_JWT_SECRET` | required | Random secret of at least 32 characters | +| `BALANCE_ALLOW_REGISTRATION` | `true` | Allows new accounts | +| `BALANCE_ACCESS_TTL` | `15m` | Access-token lifetime | +| `BALANCE_REFRESH_TTL` | `720h` | Refresh-token lifetime | +| `BALANCE_CORS_ORIGINS` | empty | Comma-separated browser origins; native apps do not need CORS | + +## HTTPS + +Use HTTPS before exposing the server to a network. The Apple applications accept plain HTTP only for `localhost` and `127.0.0.1`; a physical device should connect through an HTTPS reverse proxy such as Caddy, nginx or a private VPN with TLS. Example Caddy configuration: + +```caddyfile +balance.example.com { + reverse_proxy 127.0.0.1:8123 +} +``` + +Then enter `https://balance.example.com` in each application's Settings → Server account. + +## API + +| Method | Endpoint | Purpose | +| --- | --- | --- | +| `GET` | `/health` | Health check | +| `POST` | `/v1/auth/register` | Create account | +| `POST` | `/v1/auth/login` | Sign in | +| `POST` | `/v1/auth/refresh` | Rotate session tokens | +| `POST` | `/v1/auth/logout` | Revoke refresh token | +| `GET` | `/v1/me` | Current account | +| `POST` | `/v1/sync` | Push and pull changes | + +The sync endpoint accepts transactions, custom categories and monthly budgets. The server isolates records by account, stores an append-only change cursor, and resolves competing edits with last-write-wins timestamps. Current Apple and Android clients use push-only requests followed by paginated pulls; update the server and applications together. + +## Operational notes + +- Registration can be disabled after the required accounts have been created. +- Passwords are stored as salted Argon2id hashes. Refresh tokens are stored only as SHA-256 hashes. +- Back up the SQLite database and its `-wal`/`-shm` files together, or stop the server before copying the database. +- Changing `BALANCE_JWT_SECRET` invalidates access tokens. Existing refresh sessions remain valid and will receive new access tokens after refresh. +- The in-process authentication limiter allows 30 authentication requests per IP per minute. Use a reverse proxy for stronger public rate limiting and request logging. + +## Compatibility and migration + +The Rust server retains the HTTP/JSON API, embedded web client, environment +variables, SQLite schema, Argon2id password hashes, JWT format, refresh sessions +and sync cursor semantics of the Go implementation. Docker Compose still maps +host port **8123** to container port **8080** and keeps `/data` in `balance-data`. +Direct binary execution defaults to port 8080. + +Before replacing a running Go server, stop it, back up its database directory +(including any SQLite WAL/SHM files), and preserve `BALANCE_JWT_SECRET`. Start Rust +against the same `BALANCE_DB_PATH`; do not run both writers simultaneously. +Existing accounts, access/refresh tokens and saved device cursors remain usable. +See [deployment and rollback](docs/DEPLOYMENT.md) for the full procedure. + +Verify the implementations locally with Go 1.25+, Rust and Python 3: + +```sh +cargo test --locked +cargo fmt --check +cargo clippy --locked --all-targets -- -D warnings +cargo build --locked +python3 tools/compatibility.py +go test ./... +go vet ./... +go build ./... +``` + +The Python standard-library suite builds the Go reference, starts both binaries +on temporary loopback ports and databases, compares API responses and assets, +and tests SQLite takeover and rollback in both directions. To use release or +prebuilt executables, pass `--rust-binary PATH` and optionally `--go-binary PATH`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f4a171b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + balance-server: + build: . + restart: always + ports: + - "8123:8080" + environment: + BALANCE_ADDR: ":8080" + BALANCE_DB_PATH: "/data/balance.db" + BALANCE_JWT_SECRET: "${BALANCE_JWT_SECRET:?Set BALANCE_JWT_SECRET in .env}" + BALANCE_ALLOW_REGISTRATION: "${BALANCE_ALLOW_REGISTRATION:-true}" + BALANCE_ACCESS_TTL: "${BALANCE_ACCESS_TTL:-15m}" + BALANCE_REFRESH_TTL: "${BALANCE_REFRESH_TTL:-720h}" + BALANCE_CORS_ORIGINS: "${BALANCE_CORS_ORIGINS:-}" + volumes: + - balance-data:/data + +volumes: + balance-data: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..d32bddc --- /dev/null +++ b/docs/API.md @@ -0,0 +1,151 @@ +# HTTP API + +## Общие правила + +API предназначен для iOS/macOS/watchOS и Android Balance. + +Базовый URL задаётся клиентом и зависит от deployment environment. + +JSON: + +```http +Content-Type: application/json +``` + +Защищённые endpoints используют: + +```http +Authorization: Bearer +``` + +## Authentication + +Основные операции: + +```text +POST /v1/auth/register +POST /v1/auth/login +POST /v1/auth/logout +POST /v1/auth/refresh +``` + +### Register + +Создаёт пользовательский аккаунт. + +Input должен валидироваться сервером. Password никогда не хранится plaintext. + +### Login + +Проверяет credentials и создаёт authenticated session. + +### Refresh + +Принимает refresh credential и выдаёт новый access credential согласно текущей server-side session policy. + +### Logout + +Инвалидирует текущую session/refresh state согласно текущей реализации. + +## Synchronization + +Основной endpoint: + +```text +POST /v1/sync +``` + +Логический request: + +```json +{ + "cursor": 123, + "deviceId": "UUID", + "changes": [], + "pull": true +} +``` + +Точная JSON schema должна оставаться совместимой с `ServerSync.swift` и Android `SyncRepository`. + +## Change + +Логическая форма: + +```json +{ + "entity": "transaction", + "id": "UUID", + "deleted": false, + "updatedAt": "2026-09-08T10:00:00Z", + "payload": {} +} +``` + +Поддерживаемые entity: + +```text +transaction +category +budget +``` + +## Push + +Client отправляет локальные изменения. + +Server должен: +1. аутентифицировать пользователя; +2. проверить ownership/ID; +3. валидировать payload; +4. применить изменения; +5. создать server change entries; +6. вернуть результат и актуальную sync metadata. + +Операция должна быть безопасна при retry. + +## Pull + +Client передаёт cursor. + +Server возвращает изменения после этого cursor, включая deletions. + +Если существует pagination, ответ должен содержать понятный признак продолжения и новый cursor/sequence. + +## Ошибки + +Рекомендуемый контракт: + +```json +{ + "error": { + "code": "invalid_request", + "message": "Human-readable safe message" + } +} +``` + +Не возвращать: +- SQL error; +- stack trace; +- password/token; +- filesystem paths; +- внутренние identifiers инфраструктуры. + +## HTTP status semantics + +Рекомендуемая семантика: + +```text +200 OK — успешный request +201 Created — создание ресурса +400 Bad Request — malformed/invalid input +401 Unauthorized — missing/expired auth +403 Forbidden — resource belongs to another user +404 Not Found — resource/endpoint unavailable +409 Conflict — semantic conflict, если endpoint использует такую семантику +429 Too Many Requests — rate limit +500 Internal Server Error — internal failure +``` + +Конкретные status codes текущей реализации имеют приоритет над этой общей рекомендацией. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ef89480 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,119 @@ +# Архитектура BalanceServer + +## Роль сервера + +BalanceServer — backend для синхронизации личных финансов между устройствами. + +Поток данных: + +```text +iOS/macOS/watchOS ─┐ + ├── HTTPS/JSON API ──> BalanceServer ──> Database +Android ───────────┘ +``` + +Сервер отвечает за: +- регистрацию и авторизацию; +- пользовательские сессии; +- хранение финансовых изменений; +- push/pull synchronization; +- глобальную последовательность изменений; +- удаление через tombstones; +- выдачу изменений начиная с cursor. + +## Исходный код + +Production runtime — один Rust-процесс: `src/main.rs`, `runtime.rs`, `config.rs`, `database.rs`, `auth.rs`, `sync.rs`, `server.rs`, `go_json.rs`. SQLite bundled через rusqlite; HTTP обслуживается axum/hyper. `runtime.rs` сохраняет socket deadlines Go (header 5s, read 15s, write 30s, idle 60s) и ограничивает graceful shutdown десятью секундами. `go_json.rs` сохраняет особенности encoding/json и исходные payload bytes. Статические файлы из `internal/httpapi/web` встроены при компиляции. Go-код сохранён как эталон совместимости и вариант rollback. + +Пути сохранённой Go-реализации: + +```text +- internal/auth +- internal/config +- internal/database +- internal/httpapi +``` + +Конкретные границы package/handler/service/repository нужно сохранять при рефакторинге: перенос кода между слоями не должен менять внешний API. + +## HTTP layer + +HTTP layer принимает JSON requests от мобильных клиентов. + +Каждый handler должен: +1. проверить method/content type; +2. аутентифицировать пользователя, если endpoint защищён; +3. валидировать input; +4. вызвать domain/storage logic; +5. вернуть стабильный JSON response. + +## Domain / sync layer + +Sync layer является наиболее критичной частью backend. + +Он должен разделять: +- изменения, созданные текущим клиентом; +- изменения, которые нужно вернуть клиенту; +- удалённые записи; +- cursor/sequence. + +Ключевая модель: + +```text +client cursor + │ + ▼ +server change log + │ + ├── sequence 101 + ├── sequence 102 + ├── sequence 103 + └── ... +``` + +Клиент может запросить изменения после своего cursor и затем сохранить новый cursor. + +## Persistence + +Хранилище должно сохранять как минимум: +- user/account identity; +- credentials/session metadata; +- financial records; +- change metadata; +- deletion/tombstone metadata; +- monotonically increasing sequence, если оно является частью текущего sync protocol. + +Конкретные entities и schema документированы в `docs/DATA_MODEL.md`. + +## Security boundaries + +```text +Internet + │ + ▼ +HTTP server + │ + ├── authentication + ├── authorization + ├── input validation + │ + ▼ +domain/sync + │ + ▼ +database +``` + +Нельзя позволять клиенту выбирать произвольный `user_id` для чужих records. + +## Совместимость клиентов + +Apple и Android реализации должны видеть одну и ту же семантику данных. + +Особенно важно, чтобы: +- enum values были стабильными; +- даты имели единый формат; +- UUID/string IDs не менялись; +- `deleted` semantics были одинаковыми; +- cursor/sequence были монотонными; +- conflict resolution совпадал с клиентской логикой. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 0000000..507ebee --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,119 @@ +# Модель данных BalanceServer + +Ниже описана серверная модель на основании исходного кода проекта. При изменении schema обновляйте этот документ. + +## Найденные определения моделей/schema + +- internal/database/database.go +- internal/database/database.go +- internal/database/database.go +- internal/config/config.go +- internal/auth/tokens.go +- internal/auth/tokens.go +- internal/httpapi/auth_handlers.go +- internal/httpapi/auth_handlers.go +- internal/httpapi/auth_handlers.go +- internal/httpapi/server.go +- internal/httpapi/server.go +- internal/httpapi/server.go +- internal/httpapi/sync_handler.go +- internal/httpapi/sync_handler.go +- internal/httpapi/sync_handler.go +- internal/httpapi/web.go +- internal/httpapi/server_test.go +- internal/httpapi/server_test.go +- internal/httpapi/server_test.go + +## Общие сущности + +Сервер должен различать как минимум следующие логические типы синхронизируемых данных, совместимые с клиентами Balance: + +```text +transaction +category +budget +``` + +### Transaction + +Логически содержит: +- стабильный ID; +- amount; +- date; +- note; +- category presentation data; +- transaction kind; +- balance-adjustment flag; +- update timestamp; +- принадлежность пользователю. + +### Category + +Логически содержит: +- стабильный ID; +- name; +- icon/emoji; +- color; +- operation kind; +- creation/update metadata; +- принадлежность пользователю. + +### Budget + +Логически содержит: +- стабильный ID; +- category information; +- monthly limit; +- month start; +- update timestamp; +- принадлежность пользователю. + +## Change metadata + +Для sync необходима метаинформация: + +```text +user +entity +record ID +deleted +updatedAt +sequence +payload +``` + +`sequence` используется как серверный порядок изменений, а `updatedAt` — как timestamp версии самой записи. + +## Tombstones + +Удаление нельзя реализовывать только физическим `DELETE`, если клиентам нужно узнать о нём при следующем pull. + +Используется логическое изменение: + +```json +{ + "entity": "transaction", + "id": "…", + "deleted": true, + "updatedAt": "…" +} +``` + +После того как tombstone перестанет быть нужен для поддерживаемого sync history window, сервер может применять retention policy. Такая политика должна быть согласована с cursor semantics. + +## Invariants + +1. Record ID стабилен между sync. +2. Record принадлежит одному пользователю. +3. Клиент не может читать/изменять чужую запись. +4. `sequence` не должен уменьшаться. +5. Повторная доставка одного change не должна повреждать данные. +6. Удаление должно быть видимо клиенту через sync. +7. Timestamps должны храниться в UTC. +8. Enum/string values должны быть совместимы с iOS и Android. + +## Миграции + +Schema changes должны быть backward-compatible с активными версиями клиентов либо сопровождаться versioned API/миграцией. + +Не переименовывайте JSON field без периода совместимости. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..7ad387f --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,85 @@ +# Deployment + +## Runtime and configuration + +The production executable is the Rust server, built with `cargo build --locked +--release`. SQLite is bundled into the executable. The Go implementation remains +in `cmd/balance-server` and `internal/` as a compatibility reference and rollback +option; build it with `go build -o balance-server-go ./cmd/balance-server`. + +The Dockerfile uses a Rust 1.90 Debian Bookworm builder and a Debian Bookworm slim +runtime, running as the unprivileged `balance` user. The image embeds the existing +web client and uses no separate frontend process. Build dependencies and SQLite +are compiled at image build time. +The numeric identity remains UID 100 / GID 101, matching the original Alpine Go +image, so existing volume permissions are preserved across the switch. + +`docker compose up -d --build` publishes **http://localhost:8123** (host 8123, +container 8080). `GET /health` is the liveness endpoint. Direct binary execution +uses `:8080` by default. Place a TLS reverse proxy in front of the published port +for remote access; pass `X-Forwarded-Proto: https` to enable Secure session cookies. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `BALANCE_ADDR` | `:8080` | HTTP listen address | +| `BALANCE_DB_PATH` | `./data/balance.db` | SQLite file; Docker uses `/data/balance.db` | +| `BALANCE_JWT_SECRET` | required | At least 32 characters; retain across migration | +| `BALANCE_ACCESS_TTL` | `15m` | Go-style duration for access tokens | +| `BALANCE_REFRESH_TTL` | `720h` | Go-style duration for refresh tokens | +| `BALANCE_ALLOW_REGISTRATION` | `true` | Enable creation of accounts | +| `BALANCE_CORS_ORIGINS` | empty | Comma-separated allowed browser origins | + +Inject secrets through the deployment environment or `.env`; never put them in +images or source control. Compose preserves the existing named `balance-data` +volume mounted at `/data`. Keep that volume and its project name when upgrading. + +## Go to Rust migration + +1. Build and validate the Rust binary/image before stopping production. Retain the + previous Go executable/image and exact environment for rollback. +2. Stop the Go service and confirm no other process writes its SQLite database. +3. Back up the entire database directory, including `balance.db` and any + `balance.db-wal`/`balance.db-shm` files. Protect the backup as financial data. + Record the existing environment securely, especially `BALANCE_JWT_SECRET`. +4. Start Rust with the **same database path/volume and JWT secret**. The schema is + unchanged: startup creates missing tables and indexes without rewriting + existing accounts, records, change sequences or refresh-token hashes. +5. Check `/health`, login with an existing account, refresh an existing session, + and sync a device with its saved cursor. Verify another device receives edits + and tombstones. Inspect error rates without logging credentials or tokens. + +The existing Argon2id password hashes, HS256 JWT claims, hashed refresh tokens, +RFC3339 timestamps, entity versions and global change sequences are shared with +Go. No export/import, cursor reset, account recreation or forced sign-out is +required. Do not start both implementations against the same database at once. + +## Rollback + +Stop Rust first. Start the retained Go executable/image with the same current +database directory and secret. Rust-written accounts, password hashes, refresh +sessions, records, tombstones and cursors remain readable by Go. Check health, +login, refresh and sync again. The compatibility suite exercises both directions, +including a second takeover back to the original implementation. + +For database corruption or an unrelated failed migration, stop all writers before +restoring the complete backup directory. Restoring an older backup discards +changes after that backup and may require clients to resynchronize; prefer normal +binary rollback against the current database when it is healthy. Never overwrite +an active database or delete a Compose volume as part of an upgrade. + +## Validation and operations + +Before release run `cargo test --locked`, `cargo build --locked --release`, +`python3 tools/compatibility.py --rust-binary target/release/balance-server`, and +the retained reference checks `go test ./...`, `go vet ./...`, `go build ./...`. +The differential suite uses temporary databases and loopback listeners, compares +API/errors/security headers/assets, tests browser cookies, pagination, LWW, +tombstones, concurrent pushes, token expiration, refresh rotation, rate limiting, +and shared-database takeover/rollback in both directions. + +Keep regular encrypted backups with retention and restore drills. Monitor HTTP +5xx, sync errors, authentication failures, latency, database/change-log growth +and free disk space. The in-memory limiter allows 30 registration/login/refresh +attempts per remote IP per minute and resets on restart; use proxy controls when +operating a public service. Application logs must never include passwords, +Authorization headers or tokens. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..fd38cf6 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,120 @@ +# Разработка и запуск + +## Требования + +Production-сервер использует Rust 1.90+ и bundled SQLite. Go 1.25+ нужен для сохранённой эталонной реализации и differential-проверок. Python 3 используется без сторонних пакетов. + +Проверить локальную версию: + +```bash +rustc --version +cargo --version +go version +``` + +## Установка зависимостей + +```bash +cargo fetch --locked +go mod download +``` + +## Сборка + +```bash +cargo build --locked +go build ./... +``` + +## Тесты + +```bash +cargo test --locked +cargo build --locked +python3 tools/compatibility.py +go test ./... +``` + +С race detector: + +```bash +go test -race ./... +``` + +## Static checks + +```bash +cargo fmt --check +cargo clippy --locked --all-targets -- -D warnings +go vet ./... +``` + +Если в проекте есть Makefile/CI, его команды имеют приоритет как canonical build pipeline. + +## Локальный запуск + +Перед запуском изучите конфигурацию в коде и `.env`/deployment files. + +Не коммитьте реальные credentials. + +Собрать и запустить Rust-сервер: + +```bash +cargo build --locked --release +export BALANCE_JWT_SECRET="replace-with-a-long-random-secret" +export BALANCE_DB_PATH="./data/balance.db" +./target/release/balance-server +``` + +По умолчанию сервер слушает `:8080`. Файл `.env` читает Docker Compose; при +прямом запуске переменные нужно передать процессу через окружение. + +## Database + +SQLite встроен в executable. При первом запуске сервер создаёт каталог, +таблицы и индексы автоматически. Отдельный процесс БД не нужен. При переходе +с Go сохраните существующий файл БД и `BALANCE_JWT_SECRET`; подробная +процедура и откат описаны в [DEPLOYMENT.md](DEPLOYMENT.md). + +## Production + +Рекомендуемый pipeline: + +```text +source + ↓ +cargo fmt --check && cargo test --locked + ↓ +cargo clippy --locked --all-targets -- -D warnings + ↓ +cargo build --locked --release + ↓ +python3 tools/compatibility.py --rust-binary target/release/balance-server + ↓ +container/package + ↓ +deploy + ↓ +health check +``` + +Для production нужны: +- TLS termination; +- persistent database; +- backups; +- monitoring; +- log rotation; +- secrets management; +- migration procedure. + +## Совместимость + +При релизе server проверяйте минимум: +- старый iOS client + новый server; +- новый iOS client + server; +- Android client + server; +- concurrent sync с двух устройств; +- login/refresh после истечения access token; +- delete → pull на другом устройстве. + +Production build: `cargo build --locked --release`. Прямой запуск: `BALANCE_JWT_SECRET= target/release/balance-server`, порт 8080. Docker Compose публикует порт 8123 и сохраняет прежний volume `/data`. Процедура миграции Go → Rust и rollback: [DEPLOYMENT.md](DEPLOYMENT.md). diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..f083274 --- /dev/null +++ b/docs/PRODUCT.md @@ -0,0 +1,62 @@ +# Функциональная документация BalanceServer + +## Назначение + +BalanceServer является backend для приложения Balance — персонального финансового учёта. + +Он не реализует пользовательский UI. Его задача — безопасно хранить и синхронизировать данные между устройствами. + +## Пользовательские данные + +Синхронизируются: + +- финансовые операции; +- пользовательские категории; +- месячные бюджеты; +- удаления этих сущностей. + +## Multi-device + +Один пользователь может работать с несколькими устройствами. + +Пример: + +```text +iPhone добавил расход + ↓ + Server + ↓ +Android получил расход + ↓ +Mac получил расход +``` + +## Offline-first + +Мобильные приложения сохраняют изменения локально и затем отправляют их серверу. + +Поэтому сервер должен корректно принимать изменения спустя некоторое время после их создания. + +## Delete + +Удаление является syncable operation, а не только локальным DB delete. + +Другие устройства должны получить tombstone и удалить локальную запись. + +## Privacy + +Сервер хранит персональные финансовые данные. Поэтому production deployment должен обеспечивать: +- TLS; +- authentication; +- authorization; +- encrypted backups; +- controlled database access; +- минимальное логирование финансовых payloads. + +## Клиентская совместимость + +Backend должен рассматриваться как общий контракт для: +- Apple Balance; +- Android Balance. + +Любое изменение протокола требует проверки обеих реализаций. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..d2cc3d2 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,79 @@ +# Безопасность + +## Credentials + +Пароли пользователей должны храниться только в виде slow password hash с современным password hashing алгоритмом. + +Не хранить plaintext password. + +## Tokens + +Access/refresh tokens не должны попадать в: +- application logs; +- panic messages; +- error responses; +- analytics; +- database debug dumps. + +## Authorization + +Каждый sync mutation должен проверять ownership по authenticated user. + +Нельзя доверять `userId` из client payload, если authenticated identity уже известна из token. + +Правильная модель: + +```text +Authorization header + ↓ +authenticated user + ↓ +server-side ownership + ↓ +requested entity +``` + +## Transport + +Production API должен работать через HTTPS/TLS. + +HTTP следует оставлять только для controlled local development. + +## Input validation + +Проверяйте: +- UUID/ID; +- enum values; +- amount; +- timestamps; +- string lengths; +- batch size; +- JSON size; +- cursor/sequence bounds. + +## Logging + +Безопасно логировать: +- request ID; +- endpoint; +- status code; +- latency; +- batch size; +- non-sensitive user/record identifiers в допустимой для проекта форме. + +Нельзя логировать: +- passwords; +- Authorization headers; +- access tokens; +- refresh tokens; +- полные request bodies финансовых данных без явной необходимости. + +## Abuse protection + +Production deployment должен иметь: +- rate limiting для auth endpoints; +- reasonable request body limits; +- timeouts; +- graceful shutdown; +- database connection limits; +- monitoring. diff --git a/docs/SYNC.md b/docs/SYNC.md new file mode 100644 index 0000000..1205d90 --- /dev/null +++ b/docs/SYNC.md @@ -0,0 +1,138 @@ +# Синхронизация + +## Цель + +Один аккаунт Balance может использоваться на нескольких устройствах: + +```text +iPhone + │ + ├── transaction A + ├── category B + └── budget C + │ + ▼ + BalanceServer + │ + ┌────┴────┐ + ▼ ▼ + Android Mac +``` + +## Cursor + +Каждый клиент хранит cursor последнего успешно применённого server change. + +Принцип: + +```text +cursor = 100 + +server: +101 A +102 B +103 C + +pull(cursor=100) +→ A, B, C +→ new cursor = 103 +``` + +Клиент должен сохранять cursor только после успешного применения соответствующего batch. + +## Sequence + +Server sequence задаёт глобальный порядок изменений в sync stream. + +Требования: +- монотонность; +- отсутствие уменьшения; +- однозначность порядка; +- корректность при concurrent requests. + +## Push + +Push передаёт изменения клиента: + +```text +local mutations + ↓ +POST /v1/sync + ↓ +validation + ↓ +ownership check + ↓ +persist + ↓ +append to change stream +``` + +Retry не должен создавать две разные логические записи из одного client change. + +## Pull + +Pull получает изменения после cursor. + +Сервер не должен возвращать изменения другого пользователя. + +При pagination клиент должен иметь возможность продолжить с нового cursor. + +## Conflict resolution + +Клиенты Balance используют timestamp-based versioning. + +Типовая проверка: + +```text +local.updatedAt < remote.updatedAt +``` + +Следовательно, server должен сохранять `updatedAt` достаточно точно и не подменять его произвольным временем каждого чтения. + +При конкурентных writes сервер должен иметь однозначное правило победителя. + +## Deletions + +Удаление создаёт tombstone/change: + +```json +{ + "entity": "transaction", + "id": "...", + "deleted": true, + "updatedAt": "..." +} +``` + +Tombstone должен попасть в change stream так же, как обычное изменение. + +## Idempotency + +Push может повториться из-за: +- timeout; +- network failure; +- app restart; +- retry after 401/token refresh. + +Поэтому сервер должен обрабатывать повторную отправку безопасно. + +## Batch + +Apple-клиент отправляет batches до 100 изменений и может делить крупные payloads. + +Android должен использовать совместимый protocol. + +Server не должен предполагать, что все изменения приходят одним запросом. + +## Reset/full sync + +Если client потерял cursor или требует reset, сервер должен поддерживать предусмотренный текущим API механизм полного pull либо новый cursor baseline. + +Нельзя молча трактовать неизвестный/просроченный cursor как актуальный. + +## Security + +Sync endpoint всегда ограничен текущим authenticated user. + +`deviceId` — идентификатор устройства, а не механизм авторизации. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dcfe93b --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module balance-server + +go 1.25.0 + +require ( + golang.org/x/crypto v0.41.0 + modernc.org/sqlite v1.39.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3b7389a --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..97c9ece --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,61 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonMemory = 64 * 1024 + argonTime = 3 + argonThreads = 2 + argonKeyLen = 32 +) + +func HashPassword(password string) (string, error) { + if len(password) < 8 { + return "", errors.New("password must contain at least 8 characters") + } + if len(password) > 1024 { + return "", errors.New("password is too long") + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil +} + +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" { + return false + } + var memory uint32 + var iterations uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil { + return false + } + if memory > 256*1024 || iterations > 10 || threads > 16 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + expected, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil || len(expected) == 0 { + return false + } + actual := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(expected))) + return subtle.ConstantTimeCompare(actual, expected) == 1 +} diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go new file mode 100644 index 0000000..a037b46 --- /dev/null +++ b/internal/auth/tokens.go @@ -0,0 +1,90 @@ +package auth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" +) + +type Claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + Issuer string `json:"iss"` + Issued int64 `json:"iat"` + Expires int64 `json:"exp"` + ID string `json:"jti"` +} + +type TokenManager struct { + secret []byte + accessTTL time.Duration +} + +func NewTokenManager(secret string, accessTTL time.Duration) TokenManager { + return TokenManager{secret: []byte(secret), accessTTL: accessTTL} +} + +func (manager TokenManager) AccessToken(userID, email string) (string, time.Time, error) { + now := time.Now().UTC() + expires := now.Add(manager.accessTTL) + id, err := RandomToken(16) + if err != nil { + return "", time.Time{}, err + } + header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) + claims, _ := json.Marshal(Claims{Subject: userID, Email: email, Issuer: "balance-server", Issued: now.Unix(), Expires: expires.Unix(), ID: id}) + unsigned := encode(header) + "." + encode(claims) + signature := manager.sign(unsigned) + return unsigned + "." + signature, expires, nil +} + +func (manager TokenManager) Verify(token string) (Claims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return Claims{}, errors.New("invalid token") + } + unsigned := parts[0] + "." + parts[1] + expected := manager.sign(unsigned) + if !hmac.Equal([]byte(parts[2]), []byte(expected)) { + return Claims{}, errors.New("invalid token signature") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return Claims{}, errors.New("invalid token payload") + } + var claims Claims + if err := json.Unmarshal(payload, &claims); err != nil { + return Claims{}, errors.New("invalid token claims") + } + if claims.Issuer != "balance-server" || claims.Subject == "" || time.Now().UTC().Unix() >= claims.Expires { + return Claims{}, errors.New("expired or invalid token") + } + return claims, nil +} + +func RandomToken(bytes int) (string, error) { + value := make([]byte, bytes) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func TokenHash(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +func (manager TokenManager) sign(value string) string { + mac := hmac.New(sha256.New, manager.secret) + _, _ = mac.Write([]byte(value)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func encode(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5ddc8ec --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,93 @@ +package config + +import ( + "errors" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Address string + DatabasePath string + JWTSecret string + AccessTTL time.Duration + RefreshTTL time.Duration + AllowRegistration bool + CORSOrigins []string +} + +func Load() (Config, error) { + accessTTL, err := duration("BALANCE_ACCESS_TTL", 15*time.Minute) + if err != nil { + return Config{}, err + } + refreshTTL, err := duration("BALANCE_REFRESH_TTL", 30*24*time.Hour) + if err != nil { + return Config{}, err + } + allowRegistration, err := boolean("BALANCE_ALLOW_REGISTRATION", true) + if err != nil { + return Config{}, err + } + + cfg := Config{ + Address: env("BALANCE_ADDR", ":8080"), + DatabasePath: env("BALANCE_DB_PATH", "./data/balance.db"), + JWTSecret: strings.TrimSpace(os.Getenv("BALANCE_JWT_SECRET")), + AccessTTL: accessTTL, + RefreshTTL: refreshTTL, + AllowRegistration: allowRegistration, + CORSOrigins: split(os.Getenv("BALANCE_CORS_ORIGINS")), + } + if len(cfg.JWTSecret) < 32 { + return Config{}, errors.New("BALANCE_JWT_SECRET must contain at least 32 characters") + } + return cfg, nil +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func duration(key string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil { + return 0, errors.New(key + " must be a Go duration such as 15m or 720h") + } + return parsed, nil +} + +func boolean(key string, fallback bool) (bool, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, errors.New(key + " must be true or false") + } + return parsed, nil +} + +func split(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if item := strings.TrimSpace(part); item != "" { + result = append(result, item) + } + } + return result +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..b1c001c --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,80 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +func Open(path string) (*sql.DB, error) { + directory := filepath.Dir(path) + if directory != "." { + if err := os.MkdirAll(directory, 0o750); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + } + db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, err + } + if err := migrate(ctx, db); err != nil { + _ = db.Close() + return nil, err + } + return db, nil +} + +func migrate(ctx context.Context, db *sql.DB) error { + const schema = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens(user_id); +CREATE TABLE IF NOT EXISTS records ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity TEXT NOT NULL, + record_id TEXT NOT NULL, + payload TEXT, + updated_at TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL, + PRIMARY KEY (user_id, entity, record_id) +); +CREATE TABLE IF NOT EXISTS changes ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity TEXT NOT NULL, + record_id TEXT NOT NULL, + payload TEXT, + updated_at TEXT NOT NULL, + deleted INTEGER NOT NULL, + version INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence); +` + _, err := db.ExecContext(ctx, schema) + return err +} diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go new file mode 100644 index 0000000..0b192d3 --- /dev/null +++ b/internal/httpapi/auth_handlers.go @@ -0,0 +1,245 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "net/http" + "strings" + "time" + + "balance-server/internal/auth" +) + +type credentials struct { + Email string `json:"email"` + Password string `json:"password"` +} +type refreshRequest struct { + RefreshToken string `json:"refreshToken"` +} +type userResponse struct { + ID string `json:"id"` + Email string `json:"email"` +} +type sessionResponse struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt time.Time `json:"expiresAt"` + User userResponse `json:"user"` +} + +func (api *API) register(w http.ResponseWriter, r *http.Request) { + if !api.cfg.AllowRegistration { + writeError(w, http.StatusForbidden, "registration_disabled", "Registration is disabled") + return + } + var request credentials + if !decodeJSON(w, r, &request) { + return + } + email, err := normalizeEmail(request.Email) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_email", "Enter a valid email address") + return + } + passwordHash, err := auth.HashPassword(request.Password) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_password", err.Error()) + return + } + userID, err := identifier() + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Could not create account") + return + } + _, err = api.db.ExecContext(r.Context(), `INSERT INTO users(id,email,password_hash,created_at) VALUES(?,?,?,?)`, userID, email, passwordHash, time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + if containsUnique(err) { + writeError(w, http.StatusConflict, "email_exists", "An account with this email already exists") + return + } + api.logger.Error("create user", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Could not create account") + return + } + response, err := api.createSession(r, userID, email) + if err != nil { + api.logger.Error("create session", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Could not create session") + return + } + api.setSessionCookies(w, r, response) + writeJSON(w, http.StatusCreated, response) +} + +func (api *API) login(w http.ResponseWriter, r *http.Request) { + var request credentials + if !decodeJSON(w, r, &request) { + return + } + email, err := normalizeEmail(request.Email) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid_credentials", "Email or password is incorrect") + return + } + var userID, passwordHash string + err = api.db.QueryRowContext(r.Context(), `SELECT id,password_hash FROM users WHERE email=?`, email).Scan(&userID, &passwordHash) + if err != nil || !auth.VerifyPassword(passwordHash, request.Password) { + writeError(w, http.StatusUnauthorized, "invalid_credentials", "Email or password is incorrect") + return + } + response, err := api.createSession(r, userID, email) + if err != nil { + api.logger.Error("create session", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Could not create session") + return + } + api.setSessionCookies(w, r, response) + writeJSON(w, http.StatusOK, response) +} + +func (api *API) refresh(w http.ResponseWriter, r *http.Request) { + var request refreshRequest + if !decodeJSON(w, r, &request) { + return + } + request.RefreshToken = api.refreshToken(r, request.RefreshToken) + if request.RefreshToken == "" { + writeError(w, http.StatusBadRequest, "refresh_token_required", "Refresh token is required") + return + } + tx, err := api.db.BeginTx(r.Context(), nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session") + return + } + defer tx.Rollback() + var userID, email, expiresText string + err = tx.QueryRowContext(r.Context(), `SELECT u.id,u.email,t.expires_at FROM refresh_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=? AND t.revoked_at IS NULL`, auth.TokenHash(request.RefreshToken)).Scan(&userID, &email, &expiresText) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid_refresh_token", "Refresh token is invalid or expired") + return + } + expires, err := time.Parse(time.RFC3339Nano, expiresText) + if err != nil || !time.Now().UTC().Before(expires) { + writeError(w, http.StatusUnauthorized, "invalid_refresh_token", "Refresh token is invalid or expired") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=?`, time.Now().UTC().Format(time.RFC3339Nano), auth.TokenHash(request.RefreshToken)) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session") + return + } + response, err := api.createSessionWithExecutor(r, userID, email, tx) + if err != nil || tx.Commit() != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session") + return + } + api.setSessionCookies(w, r, response) + writeJSON(w, http.StatusOK, response) +} + +func (api *API) logout(w http.ResponseWriter, r *http.Request) { + var request refreshRequest + if !decodeJSON(w, r, &request) { + return + } + request.RefreshToken = api.refreshToken(r, request.RefreshToken) + if request.RefreshToken != "" { + _, _ = api.db.ExecContext(r.Context(), `UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=? AND revoked_at IS NULL`, time.Now().UTC().Format(time.RFC3339Nano), auth.TokenHash(request.RefreshToken)) + } + api.clearSessionCookies(w, r) + w.WriteHeader(http.StatusNoContent) +} + +func (api *API) me(w http.ResponseWriter, r *http.Request) { + value := claims(r) + writeJSON(w, http.StatusOK, userResponse{ID: value.Subject, Email: value.Email}) +} + +func (api *API) createSession(r *http.Request, userID, email string) (sessionResponse, error) { + return api.createSessionWithExecutor(r, userID, email, api.db) +} + +func (api *API) createSessionWithExecutor(r *http.Request, userID, email string, executor interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +}) (sessionResponse, error) { + accessToken, expiresAt, err := api.tokens.AccessToken(userID, email) + if err != nil { + return sessionResponse{}, err + } + refreshToken, err := auth.RandomToken(48) + if err != nil { + return sessionResponse{}, err + } + refreshExpires := time.Now().UTC().Add(api.cfg.RefreshTTL) + _, err = executor.ExecContext(r.Context(), `INSERT INTO refresh_tokens(token_hash,user_id,expires_at,created_at) VALUES(?,?,?,?)`, auth.TokenHash(refreshToken), userID, refreshExpires.Format(time.RFC3339Nano), time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return sessionResponse{}, err + } + return sessionResponse{AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, User: userResponse{ID: userID, Email: email}}, nil +} + +func identifier() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} +func containsUnique(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +} + +const ( + accessCookieName = "balance_access" + refreshCookieName = "balance_refresh" +) + +func (api *API) setSessionCookies(w http.ResponseWriter, r *http.Request, session sessionResponse) { + secure := requestIsSecure(r) + http.SetCookie(w, &http.Cookie{ + Name: accessCookieName, Value: session.AccessToken, Path: "/", HttpOnly: true, + Secure: secure, SameSite: http.SameSiteStrictMode, + Expires: session.ExpiresAt, MaxAge: maxAge(session.ExpiresAt), + }) + refreshExpires := time.Now().UTC().Add(api.cfg.RefreshTTL) + http.SetCookie(w, &http.Cookie{ + Name: refreshCookieName, Value: session.RefreshToken, Path: "/", HttpOnly: true, + Secure: secure, SameSite: http.SameSiteStrictMode, + Expires: refreshExpires, MaxAge: maxAge(refreshExpires), + }) +} + +func (api *API) clearSessionCookies(w http.ResponseWriter, r *http.Request) { + for _, name := range []string{accessCookieName, refreshCookieName} { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", HttpOnly: true, Secure: requestIsSecure(r), + SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(1, 0), + }) + } +} + +func (api *API) refreshToken(r *http.Request, value string) string { + if value = strings.TrimSpace(value); value != "" { + return value + } + if cookie, err := r.Cookie(refreshCookieName); err == nil { + return strings.TrimSpace(cookie.Value) + } + return "" +} + +func requestIsSecure(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") +} + +func maxAge(expires time.Time) int { + seconds := int(time.Until(expires).Seconds()) + if seconds < 1 { + return 1 + } + return seconds +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 0000000..27c1591 --- /dev/null +++ b/internal/httpapi/server.go @@ -0,0 +1,185 @@ +package httpapi + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net" + "net/http" + "strings" + "sync" + "time" + + "balance-server/internal/auth" + "balance-server/internal/config" +) + +type contextKey string + +const claimsKey contextKey = "claims" + +type API struct { + cfg config.Config + db *sql.DB + tokens auth.TokenManager + logger *slog.Logger + limiter *rateLimiter +} + +func New(cfg config.Config, db *sql.DB, logger *slog.Logger) http.Handler { + api := &API{cfg: cfg, db: db, tokens: auth.NewTokenManager(cfg.JWTSecret, cfg.AccessTTL), logger: logger, limiter: newRateLimiter()} + mux := http.NewServeMux() + mux.HandleFunc("GET /health", api.health) + mux.Handle("POST /v1/auth/register", api.limited(http.HandlerFunc(api.register))) + mux.Handle("POST /v1/auth/login", api.limited(http.HandlerFunc(api.login))) + mux.Handle("POST /v1/auth/refresh", api.limited(http.HandlerFunc(api.refresh))) + mux.HandleFunc("POST /v1/auth/logout", api.logout) + mux.Handle("GET /v1/me", api.authorized(http.HandlerFunc(api.me))) + mux.Handle("POST /v1/sync", api.authorized(http.HandlerFunc(api.sync))) + mux.HandleFunc("GET /", api.webApp) + return api.recover(api.security(api.cors(mux))) +} + +func (api *API) health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "time": time.Now().UTC()}) +} + +func (api *API) authorized(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := strings.TrimSpace(r.Header.Get("Authorization")) + token := "" + if strings.HasPrefix(header, "Bearer ") { + token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) + } else if cookie, err := r.Cookie(accessCookieName); err == nil { + token = strings.TrimSpace(cookie.Value) + } + if token == "" { + writeError(w, http.StatusUnauthorized, "authorization_required", "Authorization Bearer token is required") + return + } + claims, err := api.tokens.Verify(token) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid_token", "Access token is invalid or expired") + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims))) + }) +} + +func claims(r *http.Request) auth.Claims { + value, _ := r.Context().Value(claimsKey).(auth.Claims) + return value +} + +func (api *API) security(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; manifest-src 'self'; script-src 'self'; style-src 'self'; worker-src 'self'") + if strings.HasPrefix(r.URL.Path, "/v1/") { + w.Header().Set("Cache-Control", "no-store") + } + next.ServeHTTP(w, r) + }) +} + +func (api *API) cors(next http.Handler) http.Handler { + allowed := make(map[string]bool, len(api.cfg.CORSOrigins)) + for _, origin := range api.cfg.CORSOrigins { + allowed[origin] = true + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && (allowed[origin] || allowed["*"]) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func (api *API) recover(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if value := recover(); value != nil { + api.logger.Error("request panic", "value", value) + writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") + } + }() + next.ServeHTTP(w, r) + }) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, destination any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 2<<20) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", "Request body is not valid JSON") + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}}) +} + +type rateLimiter struct { + mu sync.Mutex + clients map[string]*rateBucket +} +type rateBucket struct { + started time.Time + count int +} + +func newRateLimiter() *rateLimiter { return &rateLimiter{clients: make(map[string]*rateBucket)} } +func (api *API) limited(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + now := time.Now() + api.limiter.mu.Lock() + bucket := api.limiter.clients[host] + if bucket == nil || now.Sub(bucket.started) >= time.Minute { + bucket = &rateBucket{started: now} + api.limiter.clients[host] = bucket + } + bucket.count++ + blocked := bucket.count > 30 + api.limiter.mu.Unlock() + if blocked { + w.Header().Set("Retry-After", "60") + writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many authentication requests") + return + } + next.ServeHTTP(w, r) + }) +} + +func normalizeEmail(value string) (string, error) { + email := strings.ToLower(strings.TrimSpace(value)) + if len(email) < 3 || len(email) > 254 || !strings.Contains(email, "@") { + return "", errors.New("invalid email") + } + return email, nil +} diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go new file mode 100644 index 0000000..07b4028 --- /dev/null +++ b/internal/httpapi/server_test.go @@ -0,0 +1,204 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "balance-server/internal/config" + "balance-server/internal/database" +) + +func TestRegisterAndSynchronize(t *testing.T) { + db, err := database.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + cfg := config.Config{JWTSecret: "test-secret-that-is-longer-than-thirty-two-characters", AccessTTL: 15 * time.Minute, RefreshTTL: time.Hour, AllowRegistration: true} + server := httptest.NewServer(New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) + defer server.Close() + + register := request(t, http.MethodPost, server.URL+"/v1/auth/register", map[string]any{"email": "person@example.com", "password": "correct horse battery staple"}, "") + if register.StatusCode != http.StatusCreated { + t.Fatalf("register status: %d", register.StatusCode) + } + var session sessionResponse + decode(t, register, &session) + if session.AccessToken == "" || session.RefreshToken == "" { + t.Fatal("session tokens are empty") + } + + now := time.Now().UTC().Add(-time.Second) + pull := false + syncBody := syncRequest{DeviceID: "test", Pull: &pull, Changes: []syncChange{{Entity: "category", ID: "category-1", UpdatedAt: now, Payload: json.RawMessage(`{"name":"Food"}`)}}} + synchronized := request(t, http.MethodPost, server.URL+"/v1/sync", syncBody, session.AccessToken) + if synchronized.StatusCode != http.StatusOK { + t.Fatalf("sync status: %d", synchronized.StatusCode) + } + var result syncResponse + decode(t, synchronized, &result) + if result.Cursor != 0 || len(result.Changes) != 0 { + t.Fatalf("push-only response included pulled changes: %+v", result) + } + + pull = true + synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{DeviceID: "test", Pull: &pull}, session.AccessToken) + if synchronized.StatusCode != http.StatusOK { + t.Fatalf("pull status: %d", synchronized.StatusCode) + } + decode(t, synchronized, &result) + if result.Cursor == 0 || len(result.Changes) != 1 || result.Changes[0].ID != "category-1" { + t.Fatalf("unexpected sync response: %+v", result) + } + + pageStart := result.Cursor + pageChanges := make([]syncChange, syncPageSize+1) + for index := range pageChanges { + pageChanges[index] = syncChange{ + Entity: "category", ID: "page-" + time.Unix(int64(index), 0).UTC().Format("150405"), + UpdatedAt: now.Add(time.Duration(index+1) * time.Millisecond), Payload: json.RawMessage(`{"name":"Paged"}`), + } + } + pull = false + synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{DeviceID: "test", Pull: &pull, Changes: pageChanges}, session.AccessToken) + if synchronized.StatusCode != http.StatusOK { + t.Fatalf("paged push status: %d", synchronized.StatusCode) + } + synchronized.Body.Close() + + pull = true + synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{Cursor: pageStart, DeviceID: "test", Pull: &pull}, session.AccessToken) + decode(t, synchronized, &result) + if len(result.Changes) != syncPageSize || !result.HasMore { + t.Fatalf("first page mismatch: count=%d hasMore=%v", len(result.Changes), result.HasMore) + } + synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{Cursor: result.Cursor, DeviceID: "test", Pull: &pull}, session.AccessToken) + decode(t, synchronized, &result) + if len(result.Changes) != 1 || result.HasMore { + t.Fatalf("last page mismatch: count=%d hasMore=%v", len(result.Changes), result.HasMore) + } +} + +func TestEmbeddedWebAppAndCookieSession(t *testing.T) { + db, err := database.Open(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + cfg := config.Config{JWTSecret: "test-secret-that-is-longer-than-thirty-two-characters", AccessTTL: 15 * time.Minute, RefreshTTL: time.Hour, AllowRegistration: true} + server := httptest.NewServer(New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) + defer server.Close() + + home, err := http.Get(server.URL + "/") + if err != nil { + t.Fatal(err) + } + homeBody, err := io.ReadAll(home.Body) + home.Body.Close() + if err != nil { + t.Fatal(err) + } + if home.StatusCode != http.StatusOK || !strings.Contains(string(homeBody), "id=\"app-view\"") { + t.Fatalf("web app response: status=%d body=%q", home.StatusCode, string(homeBody)) + } + if !strings.Contains(home.Header.Get("Content-Security-Policy"), "script-src 'self'") { + t.Fatal("web app is missing its content security policy") + } + + register := request(t, http.MethodPost, server.URL+"/v1/auth/register", map[string]any{"email": "web@example.com", "password": "correct horse battery staple"}, "") + if register.StatusCode != http.StatusCreated { + t.Fatalf("register status: %d", register.StatusCode) + } + cookies := register.Cookies() + var session sessionResponse + decode(t, register, &session) + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + switch cookie.Name { + case accessCookieName: + accessCookie = cookie + case refreshCookieName: + refreshCookie = cookie + } + if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("insecure session cookie: %+v", cookie) + } + } + if accessCookie == nil || refreshCookie == nil { + t.Fatal("session cookies were not returned") + } + + meRequest, err := http.NewRequest(http.MethodGet, server.URL+"/v1/me", nil) + if err != nil { + t.Fatal(err) + } + meRequest.AddCookie(accessCookie) + me, err := http.DefaultClient.Do(meRequest) + if err != nil { + t.Fatal(err) + } + if me.StatusCode != http.StatusOK { + t.Fatalf("cookie authentication status: %d", me.StatusCode) + } + var user userResponse + decode(t, me, &user) + if user.Email != "web@example.com" { + t.Fatalf("unexpected web user: %+v", user) + } + + refreshData, err := json.Marshal(refreshRequest{}) + if err != nil { + t.Fatal(err) + } + refreshHTTP, err := http.NewRequest(http.MethodPost, server.URL+"/v1/auth/refresh", bytes.NewReader(refreshData)) + if err != nil { + t.Fatal(err) + } + refreshHTTP.Header.Set("Content-Type", "application/json") + refreshHTTP.AddCookie(refreshCookie) + refreshed, err := http.DefaultClient.Do(refreshHTTP) + if err != nil { + t.Fatal(err) + } + if refreshed.StatusCode != http.StatusOK { + t.Fatalf("cookie refresh status: %d", refreshed.StatusCode) + } + refreshed.Body.Close() +} + +func request(t *testing.T, method, url string, value any, token string) *http.Response { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(method, url, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return response +} + +func decode(t *testing.T, response *http.Response, value any) { + t.Helper() + defer response.Body.Close() + if err := json.NewDecoder(response.Body).Decode(value); err != nil { + t.Fatal(err) + } +} diff --git a/internal/httpapi/sync_handler.go b/internal/httpapi/sync_handler.go new file mode 100644 index 0000000..d3c8f55 --- /dev/null +++ b/internal/httpapi/sync_handler.go @@ -0,0 +1,169 @@ +package httpapi + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "time" +) + +const maxSyncChanges = 5000 +const syncPageSize = 500 + +type syncChange struct { + Entity string `json:"entity"` + ID string `json:"id"` + Deleted bool `json:"deleted"` + UpdatedAt time.Time `json:"updatedAt"` + Payload json.RawMessage `json:"payload,omitempty"` + Version int64 `json:"version,omitempty"` + Sequence int64 `json:"sequence,omitempty"` +} +type syncRequest struct { + Cursor int64 `json:"cursor"` + DeviceID string `json:"deviceId"` + Changes []syncChange `json:"changes"` + Pull *bool `json:"pull,omitempty"` +} +type syncResponse struct { + Cursor int64 `json:"cursor"` + Changes []syncChange `json:"changes"` + ServerTime time.Time `json:"serverTime"` + HasMore bool `json:"hasMore"` +} + +func (api *API) sync(w http.ResponseWriter, r *http.Request) { + var request syncRequest + if !decodeJSON(w, r, &request) { + return + } + if request.Cursor < 0 || len(request.Changes) > maxSyncChanges { + writeError(w, http.StatusBadRequest, "invalid_sync_request", "Cursor or change count is invalid") + return + } + for index := range request.Changes { + if err := validateChange(request.Changes[index]); err != nil { + writeError(w, http.StatusBadRequest, "invalid_change", err.Error()) + return + } + } + userID := claims(r).Subject + tx, err := api.db.BeginTx(r.Context(), nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "sync_failed", "Could not begin synchronization") + return + } + defer tx.Rollback() + for _, change := range request.Changes { + if err := applyChange(r, tx, userID, change); err != nil { + api.logger.Error("apply sync change", "error", err) + writeError(w, http.StatusInternalServerError, "sync_failed", "Could not apply changes") + return + } + } + changes := make([]syncChange, 0) + cursor := request.Cursor + hasMore := false + shouldPull := request.Pull == nil || *request.Pull + if shouldPull { + changes, cursor, hasMore, err = loadChanges(r, tx, userID, request.Cursor) + if err != nil { + api.logger.Error("load sync changes", "error", err) + writeError(w, http.StatusInternalServerError, "sync_failed", "Could not load changes") + return + } + } + if err := tx.Commit(); err != nil { + writeError(w, http.StatusInternalServerError, "sync_failed", "Could not complete synchronization") + return + } + writeJSON(w, http.StatusOK, syncResponse{Cursor: cursor, Changes: changes, ServerTime: time.Now().UTC(), HasMore: hasMore}) +} + +func validateChange(change syncChange) error { + if change.Entity != "transaction" && change.Entity != "category" && change.Entity != "budget" { + return errors.New("entity must be transaction, category, or budget") + } + if len(change.ID) < 1 || len(change.ID) > 128 { + return errors.New("record id is invalid") + } + if change.UpdatedAt.IsZero() { + return errors.New("updatedAt is required") + } + if change.UpdatedAt.After(time.Now().UTC().Add(24 * time.Hour)) { + return errors.New("updatedAt is too far in the future") + } + if !change.Deleted && (len(change.Payload) == 0 || !json.Valid(change.Payload)) { + return errors.New("payload is required for active records") + } + if len(change.Payload) > 256*1024 { + return errors.New("payload is too large") + } + return nil +} + +func applyChange(r *http.Request, tx *sql.Tx, userID string, incoming syncChange) error { + var currentUpdated string + var currentVersion int64 + err := tx.QueryRowContext(r.Context(), `SELECT updated_at,version FROM records WHERE user_id=? AND entity=? AND record_id=?`, userID, incoming.Entity, incoming.ID).Scan(¤tUpdated, ¤tVersion) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if err == nil { + parsed, parseErr := time.Parse(time.RFC3339Nano, currentUpdated) + if parseErr != nil { + return parseErr + } + if !incoming.UpdatedAt.After(parsed) { + return nil + } + } + version := currentVersion + 1 + updated := incoming.UpdatedAt.UTC().Format(time.RFC3339Nano) + var payload any + if !incoming.Deleted { + payload = string(incoming.Payload) + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO records(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?) + ON CONFLICT(user_id,entity,record_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at,deleted=excluded.deleted,version=excluded.version`, + userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version) + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?)`, userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version) + return err +} + +func loadChanges(r *http.Request, tx *sql.Tx, userID string, after int64) ([]syncChange, int64, bool, error) { + rows, err := tx.QueryContext(r.Context(), `SELECT sequence,entity,record_id,payload,updated_at,deleted,version FROM changes WHERE user_id=? AND sequence>? ORDER BY sequence ASC LIMIT ?`, userID, after, syncPageSize+1) + if err != nil { + return nil, after, false, err + } + defer rows.Close() + result := make([]syncChange, 0) + cursor := after + hasMore := false + for rows.Next() { + if len(result) == syncPageSize { + hasMore = true + break + } + var item syncChange + var payload sql.NullString + var updated string + if err := rows.Scan(&item.Sequence, &item.Entity, &item.ID, &payload, &updated, &item.Deleted, &item.Version); err != nil { + return nil, after, false, err + } + item.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated) + if err != nil { + return nil, after, false, err + } + if payload.Valid { + item.Payload = json.RawMessage(payload.String) + } + cursor = item.Sequence + result = append(result, item) + } + return result, cursor, hasMore, rows.Err() +} diff --git a/internal/httpapi/web.go b/internal/httpapi/web.go new file mode 100644 index 0000000..d593821 --- /dev/null +++ b/internal/httpapi/web.go @@ -0,0 +1,34 @@ +package httpapi + +import ( + "embed" + "io/fs" + "net/http" + "path" + "strings" +) + +//go:embed web/* +var embeddedWeb embed.FS + +var webContent = func() http.Handler { + content, err := fs.Sub(embeddedWeb, "web") + if err != nil { + panic(err) + } + return http.FileServer(http.FS(content)) +}() + +func (api *API) webApp(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/v1/") { + writeError(w, http.StatusNotFound, "not_found", "Endpoint not found") + return + } + cleaned := path.Clean(r.URL.Path) + if cleaned == "." || cleaned == "/" || cleaned == "/index.html" || cleaned == "/service-worker.js" { + w.Header().Set("Cache-Control", "no-cache") + } else { + w.Header().Set("Cache-Control", "public, max-age=3600") + } + webContent.ServeHTTP(w, r) +} diff --git a/internal/httpapi/web/app.js b/internal/httpapi/web/app.js new file mode 100644 index 0000000..e5bed6a --- /dev/null +++ b/internal/httpapi/web/app.js @@ -0,0 +1,1058 @@ +"use strict"; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + +const COLORS = [ + "red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", + "indigo", "purple", "pink", "gray", "coral", "peach", "gold", "lime", + "forest", "turquoise", "sky", "navy", "lavender", "magenta", "brown", "slate", +]; + +const ICONS = [ + ["star.fill", "★"], ["heart.fill", "♥"], ["sparkles", "✦"], ["bolt.fill", "ϟ"], ["flame.fill", "♨"], + ["drop.fill", "●"], ["cart.fill", "🛒"], ["bag.fill", "▣"], ["creditcard.fill", "▤"], ["banknote.fill", "₽"], + ["gift.fill", "🎁"], ["tag.fill", "◇"], ["cup.and.saucer.fill", "☕"], ["fork.knife", "♨"], ["takeoutbag.and.cup.and.straw.fill", "🥡"], + ["birthday.cake.fill", "🎂"], ["car.fill", "🚗"], ["bus.fill", "🚌"], ["tram.fill", "🚊"], ["bicycle", "🚲"], + ["airplane", "✈"], ["fuelpump.fill", "⛽"], ["house.fill", "⌂"], ["bed.double.fill", "▰"], ["lightbulb.fill", "☀"], + ["wifi", "⌁"], ["phone.fill", "▯"], ["printer.fill", "▤"], ["pawprint.fill", "🐾"], ["leaf.fill", "♧"], + ["figure.walk", "♙"], ["figure.run", "➜"], ["dumbbell.fill", "↔"], ["cross.case.fill", "+"], ["pills.fill", "◉"], + ["stethoscope", "∿"], ["book.fill", "▥"], ["graduationcap.fill", "⌃"], ["laptopcomputer", "▰"], ["briefcase.fill", "▣"], + ["gamecontroller.fill", "⌘"], ["music.note", "♫"], ["film.fill", "▦"], ["camera.fill", "◉"], ["paintpalette.fill", "◒"], + ["tshirt.fill", "♜"], ["hammer.fill", "⚒"], ["wrench.and.screwdriver.fill", "⚙"], ["shippingbox.fill", "⬡"], ["globe.europe.africa.fill", "◎"], +]; + +const EMOJIS = [ + "🍔", "☕️", "🛒", "🚕", "✈️", "🏠", "💡", "📱", "💻", "🎮", + "🎬", "🎵", "📚", "🎓", "💊", "🏋️", "🐶", "🐱", "👕", "🎁", + "💰", "💳", "📈", "🔧", "🌿", "❤️", "⭐️", "🎯", "🧾", "🏖️", +]; + +const BUILT_IN = { + expense: [ + ["Продукты", "cart.fill", "orange"], ["Транспорт", "car.fill", "blue"], + ["Дом", "house.fill", "indigo"], ["Развлечения", "gamecontroller.fill", "pink"], + ["Здоровье", "cross.case.fill", "red"], ["Покупки", "bag.fill", "purple"], + ["Подписки", "repeat", "cyan"], ["Образование", "book.fill", "mint"], + ["Другое", "ellipsis.circle.fill", "gray"], + ], + income: [ + ["Зарплата", "banknote.fill", "green"], ["Подработка", "laptopcomputer", "teal"], + ["Инвестиции", "chart.line.uptrend.xyaxis", "blue"], ["Подарок", "gift.fill", "pink"], + ["Другое", "plus.circle.fill", "gray"], + ], +}; + +const ICON_GLYPHS = new Map(ICONS); +const EXTRA_GLYPHS = { + "repeat": "↻", "ellipsis.circle.fill": "•••", "plus.circle.fill": "+", + "laptopcomputer": "▰", "chart.line.uptrend.xyaxis": "↗", "tune": "≡", +}; + +const state = { + user: null, + route: "dashboard", + transactions: new Map(), + categories: new Map(), + budgets: new Map(), + cursor: 0, + syncing: false, + analyticsMonths: 1, + transactionQuery: "", + transactionKind: "all", + authMode: "login", + lastSyncAt: null, +}; + +const preferences = loadPreferences(); +const mediaDark = window.matchMedia("(prefers-color-scheme: dark)"); + +class APIError extends Error { + constructor(message, code, status) { + super(message); + this.name = "APIError"; + this.code = code; + this.status = status; + } +} + +function loadPreferences() { + try { + const value = JSON.parse(localStorage.getItem("balance.web.preferences") || "{}"); + return { + currency: ["RUB", "EUR", "USD", "SEK"].includes(value.currency) ? value.currency : "RUB", + theme: ["system", "light", "dark"].includes(value.theme) ? value.theme : "system", + }; + } catch { + return { currency: "RUB", theme: "system" }; + } +} + +function savePreferences() { + localStorage.setItem("balance.web.preferences", JSON.stringify(preferences)); +} + +function applyTheme() { + const dark = preferences.theme === "dark" || (preferences.theme === "system" && mediaDark.matches); + document.documentElement.dataset.theme = dark ? "dark" : "light"; + $("meta[name='theme-color']")?.setAttribute("content", dark ? "#1b1b21" : "#5b5bd6"); +} + +function escapeHTML(value) { + return String(value ?? "").replace(/[&<>"']/g, character => ({ + "&": "&", "<": "<", ">": ">", "\"": """, "'": "'", + })[character]); +} + +function safeColor(value) { + return COLORS.includes(value) ? value : "indigo"; +} + +function iconGlyph(value) { + return ICON_GLYPHS.get(value) || EXTRA_GLYPHS[value] || "◆"; +} + +function categoryBadge(item, size = "") { + const visual = item.emoji ? escapeHTML(item.emoji) : `${escapeHTML(iconGlyph(item.icon))}`; + return `${visual}`; +} + +function formatMoney(value, options = {}) { + const amount = Number.isFinite(Number(value)) ? Number(value) : 0; + return new Intl.NumberFormat("ru-RU", { + style: "currency", + currency: preferences.currency, + maximumFractionDigits: Math.abs(amount % 1) < 0.0001 ? 0 : 2, + ...options, + }).format(amount); +} + +function formatDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short", year: "numeric" }).format(date); +} + +function formatDateTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "никогда"; + return new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(date); +} + +function localDateInput(value = new Date()) { + const date = new Date(value); + const offset = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offset).toISOString().slice(0, 10); +} + +function dateInputISO(value) { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day, 0, 0, 0, 0).toISOString(); +} + +function randomID() { + return crypto.randomUUID ? crypto.randomUUID() : `${Date.now().toString(36)}-${crypto.getRandomValues(new Uint32Array(2)).join("-")}`; +} + +function firstGrapheme(value) { + const trimmed = String(value || "").trim(); + if (!trimmed) return ""; + if (Intl.Segmenter) return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(trimmed)][0]?.segment || ""; + return Array.from(trimmed)[0] || ""; +} + +function allTransactions() { + return [...state.transactions.values()]; +} + +function allCategories() { + return [...state.categories.values()]; +} + +function allBudgets() { + return [...state.budgets.values()]; +} + +function categoriesFor(kind) { + const builtIn = BUILT_IN[kind].map(([name, icon, colorName]) => ({ name, icon, emoji: "", colorName, kindRawValue: kind, builtIn: true })); + const custom = allCategories() + .filter(category => category.kindRawValue === kind) + .sort((a, b) => a.name.localeCompare(b.name, "ru")); + return [...builtIn, ...custom]; +} + +function monthBounds(months = 1) { + const now = new Date(); + const end = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const start = new Date(now.getFullYear(), now.getMonth() - (months - 1), 1); + return [start.getTime(), end.getTime()]; +} + +function inPeriod(transaction, start, end) { + const time = new Date(transaction.date).getTime(); + return Number.isFinite(time) && time >= start && time < end; +} + +function totalBalance() { + return allTransactions().reduce((sum, transaction) => sum + (transaction.kindRawValue === "income" ? transaction.amount : -transaction.amount), 0); +} + +function summaryFor(months = 1) { + const [start, end] = monthBounds(months); + const items = allTransactions().filter(item => !item.isBalanceAdjustment && inPeriod(item, start, end)); + return { + income: items.filter(item => item.kindRawValue === "income").reduce((sum, item) => sum + item.amount, 0), + expenses: items.filter(item => item.kindRawValue === "expense").reduce((sum, item) => sum + item.amount, 0), + items, + start, + end, + }; +} + +function spendingBreakdown(months = 1) { + const { items } = summaryFor(months); + const groups = new Map(); + for (const item of items.filter(value => value.kindRawValue === "expense")) { + const group = groups.get(item.categoryName) || { + name: item.categoryName, icon: item.categoryIcon, emoji: item.categoryEmoji, + colorName: item.categoryColorName, amount: 0, + }; + group.amount += Number(item.amount) || 0; + groups.set(item.categoryName, group); + } + return [...groups.values()].sort((a, b) => b.amount - a.amount); +} + +function currentBudget(categoryName) { + const now = new Date(); + return allBudgets() + .filter(item => item.categoryName === categoryName) + .filter(item => { + const date = new Date(item.monthStart); + return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth(); + }) + .sort((a, b) => new Date(b._updatedAt) - new Date(a._updatedAt))[0] || null; +} + +function currentMonthStartISO() { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1).toISOString(); +} + +async function apiFetch(path, options = {}, retry = true) { + const headers = new Headers(options.headers || {}); + if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + const response = await fetch(path, { ...options, headers, credentials: "same-origin" }); + if (response.status === 401 && retry && path !== "/v1/auth/refresh") { + const refreshed = await fetch("/v1/auth/refresh", { + method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: "" }), + }); + if (refreshed.ok) return apiFetch(path, options, false); + } + let payload = null; + if (response.status !== 204) { + const type = response.headers.get("Content-Type") || ""; + payload = type.includes("application/json") ? await response.json().catch(() => null) : await response.text().catch(() => ""); + } + if (!response.ok) { + const code = payload?.error?.code || "request_failed"; + throw new APIError(localizedError(code, payload?.error?.message), code, response.status); + } + return payload; +} + +function localizedError(code, fallback) { + const messages = { + invalid_credentials: "Неверный email или пароль.", + invalid_email: "Введите корректный email.", + invalid_password: "Пароль должен содержать не менее 8 символов.", + email_exists: "Аккаунт с таким email уже существует.", + registration_disabled: "Регистрация на этом сервере отключена.", + rate_limited: "Слишком много попыток. Повторите через минуту.", + authorization_required: "Необходимо войти в аккаунт.", + invalid_token: "Сессия истекла. Войдите снова.", + invalid_refresh_token: "Сессия истекла. Войдите снова.", + sync_failed: "Не удалось синхронизировать данные.", + }; + return messages[code] || fallback || "Не удалось выполнить запрос."; +} + +function applyChanges(changes) { + for (const change of [...(changes || [])].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))) { + const target = change.entity === "transaction" ? state.transactions : change.entity === "category" ? state.categories : change.entity === "budget" ? state.budgets : null; + if (!target) continue; + if (change.deleted) target.delete(change.id); + else if (change.payload) target.set(change.id, { id: change.id, ...change.payload, _updatedAt: change.updatedAt }); + } +} + +async function fullSync() { + if (!state.user || state.syncing) return; + state.transactions.clear(); + state.categories.clear(); + state.budgets.clear(); + state.cursor = 0; + await pullChanges(true); +} + +async function pullChanges(silent = false) { + if (!state.user || state.syncing) return; + state.syncing = true; + setSyncStatus("syncing", "Синхронизация…"); + try { + let hasMore = true; + let guard = 0; + while (hasMore) { + const previous = state.cursor; + const response = await apiFetch("/v1/sync", { + method: "POST", + body: JSON.stringify({ cursor: state.cursor, deviceId: webDeviceID(), changes: [], pull: true }), + }); + applyChanges(response.changes); + state.cursor = response.cursor; + hasMore = Boolean(response.hasMore); + if (hasMore && state.cursor <= previous) throw new Error("Сервер не смог продолжить синхронизацию."); + if (++guard > 10000) throw new Error("Слишком много страниц синхронизации."); + } + state.lastSyncAt = new Date(); + setSyncStatus("ok", "Синхронизировано"); + render(); + if (!silent) toast("Данные синхронизированы"); + } catch (error) { + handleSyncError(error, silent); + throw error; + } finally { + state.syncing = false; + } +} + +async function pushChanges(changes) { + if (!state.user || !changes.length) return; + if (state.syncing) throw new Error("Дождитесь завершения синхронизации."); + state.syncing = true; + setSyncStatus("syncing", "Сохранение…"); + try { + for (let offset = 0; offset < changes.length; offset += 100) { + const batch = changes.slice(offset, offset + 100); + let response = await apiFetch("/v1/sync", { + method: "POST", + body: JSON.stringify({ cursor: state.cursor, deviceId: webDeviceID(), changes: batch, pull: true }), + }); + applyChanges(response.changes); + state.cursor = response.cursor; + while (response.hasMore) { + const previous = state.cursor; + response = await apiFetch("/v1/sync", { + method: "POST", + body: JSON.stringify({ cursor: state.cursor, deviceId: webDeviceID(), changes: [], pull: true }), + }); + applyChanges(response.changes); + state.cursor = response.cursor; + if (response.hasMore && state.cursor <= previous) throw new Error("Сервер не смог продолжить синхронизацию."); + } + } + state.lastSyncAt = new Date(); + setSyncStatus("ok", "Синхронизировано"); + render(); + } catch (error) { + handleSyncError(error, false); + throw error; + } finally { + state.syncing = false; + } +} + +function handleSyncError(error, silent) { + setSyncStatus("error", "Ошибка синхронизации"); + if (error instanceof APIError && error.status === 401) showAuth(); + if (!silent) toast(error.message || "Ошибка синхронизации", true); +} + +function webDeviceID() { + let value = localStorage.getItem("balance.web.device"); + if (!value) { + value = `web-${randomID()}`; + localStorage.setItem("balance.web.device", value); + } + return value; +} + +function changeFor(entity, id, payload, deleted = false, updatedAt = new Date().toISOString()) { + return { entity, id, deleted, updatedAt, ...(deleted ? {} : { payload }) }; +} + +function transactionPayload(value) { + return { + amount: Number(value.amount), date: value.date, note: value.note || "", + categoryName: value.categoryName, categoryIcon: value.categoryIcon || "circle", + categoryEmoji: value.categoryEmoji || "", categoryColorName: value.categoryColorName || "indigo", + isBalanceAdjustment: Boolean(value.isBalanceAdjustment), kindRawValue: value.kindRawValue === "income" ? "income" : "expense", + }; +} + +function categoryPayload(value) { + return { + name: value.name, icon: value.icon || "star", emoji: value.emoji || "", colorName: safeColor(value.colorName), + kindRawValue: value.kindRawValue === "income" ? "income" : "expense", createdAt: value.createdAt, + }; +} + +function budgetPayload(value) { + return { + categoryName: value.categoryName, categoryIcon: value.categoryIcon || "circle", + categoryEmoji: value.categoryEmoji || "", limit: Number(value.limit), monthStart: value.monthStart, + }; +} + +function pageHeader(title, subtitle, actions = "") { + return ``; +} + +function emptyState(icon, title, text, action = "") { + return `
${escapeHTML(icon)}

${escapeHTML(title)}

${escapeHTML(text)}

${action}
`; +} + +function render() { + if (!state.user) return; + const renderers = { + dashboard: renderDashboard, + transactions: renderTransactions, + analytics: renderAnalytics, + budgets: renderBudgets, + categories: renderCategories, + settings: renderSettings, + }; + $("#content").innerHTML = (renderers[state.route] || renderDashboard)(); + $$('[data-route]').forEach(button => button.classList.toggle("is-active", button.dataset.route === state.route)); + document.title = `${routeTitle(state.route)} — Balance`; +} + +function routeTitle(route) { + return ({ dashboard: "Обзор", transactions: "Операции", analytics: "Аналитика", budgets: "Бюджеты", categories: "Категории", settings: "Настройки" })[route] || "Balance"; +} + +function renderDashboard() { + const summary = summaryFor(1); + const recent = allTransactions().sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 6); + const savings = summary.income > 0 ? ((summary.income - summary.expenses) / summary.income) * 100 : 0; + return `${pageHeader("Обзор", "Главное о ваших финансах за текущий месяц", ` + + `)} +
+
+

Общий баланс

+
${formatMoney(totalBalance())}
+
+
Доходы за месяц+ ${formatMoney(summary.income)}
+
Расходы за месяц− ${formatMoney(summary.expenses)}
+
+ +
+
+
Доходы${formatMoney(summary.income)}
Текущий месяц
+
Расходы${formatMoney(summary.expenses)}
Без корректировок
+
Сбережения${formatMoney(summary.income - summary.expenses)}
Доходы минус расходы
+
Норма сбережений${Number.isFinite(savings) ? `${Math.round(savings)}%` : "—"}
За текущий месяц
+
+
+

Последние операции

+
${recent.length ? `
${recent.map(transactionRow).join("")}
` : emptyState("↕", "Операций пока нет", "Добавьте первый доход или расход, и он появится на всех ваших устройствах.", ``)}
+
+
`; +} + +function transactionRow(transaction) { + const income = transaction.kindRawValue === "income"; + const item = { + emoji: transaction.categoryEmoji, icon: transaction.categoryIcon, + colorName: transaction.categoryColorName, + }; + const category = transaction.isBalanceAdjustment ? "Корректировка" : transaction.categoryName; + const note = transaction.note ? ` · ${escapeHTML(transaction.note)}` : ""; + return `
+ ${categoryBadge(item)} +
${escapeHTML(category)}${formatDate(transaction.date)}${note}
+
${income ? "+" : "−"} ${formatMoney(transaction.amount)}${income ? "Доход" : "Расход"}
+
+ + +
+
`; +} + +function filteredTransactions() { + const query = state.transactionQuery.trim().toLocaleLowerCase("ru"); + return allTransactions() + .filter(item => state.transactionKind === "all" || item.kindRawValue === state.transactionKind) + .filter(item => !query || `${item.categoryName} ${item.note || ""}`.toLocaleLowerCase("ru").includes(query)) + .sort((a, b) => new Date(b.date) - new Date(a.date)); +} + +function transactionListHTML() { + const items = filteredTransactions(); + return items.length ? `
${items.map(transactionRow).join("")}
` : emptyState("⌕", "Ничего не найдено", "Измените поиск или фильтр, либо добавьте новую операцию."); +} + +function renderTransactions() { + return `${pageHeader("Операции", "Доходы, расходы и ручные корректировки баланса", ``)} +
+
+ +
+
${transactionListHTML()}
`; +} + +function renderAnalytics() { + const months = state.analyticsMonths; + const summary = summaryFor(months); + const breakdown = spendingBreakdown(months); + const savings = summary.income > 0 ? ((summary.income - summary.expenses) / summary.income) * 100 : 0; + const max = Math.max(...breakdown.map(item => item.amount), 1); + const periodName = months === 1 ? "месяц" : `${months} месяца`; + const insight = summary.income === 0 + ? "Добавьте доходы, чтобы увидеть норму сбережений и персональную подсказку." + : savings >= 20 + ? `Вы сохраняете ${Math.round(savings)}% доходов — это сильный результат.` + : savings >= 0 + ? `Вы сохраняете ${Math.round(savings)}% доходов. Попробуйте постепенно приблизиться к 20%.` + : "Расходы выше доходов за выбранный период. Проверьте самые крупные категории."; + return `
+ ${pageHeader("Аналитика", "Структура расходов и динамика сбережений", ``)} +
+ + + +
+
+
Доходы${formatMoney(summary.income)}
За ${periodName}
+
Расходы${formatMoney(summary.expenses)}
За ${periodName}
+
Сбережения${formatMoney(summary.income - summary.expenses)}
Чистый результат
+
Норма${summary.income ? `${Math.round(savings)}%` : "—"}
От доходов
+
+
+
+

Расходы по категориям

${formatMoney(summary.expenses)}
+
${breakdown.length ? `
${breakdown.map(item => ` +
+
${categoryBadge(item, "small")}${escapeHTML(item.name)}
+ +
${formatMoney(item.amount)}
+
`).join("")}
` : emptyState("◫", "Недостаточно данных", "В выбранном периоде ещё нет расходов.")}
+
+ +
+
`; +} + +function renderBudgets() { + const [start, end] = monthBounds(1); + const spending = new Map(); + for (const transaction of allTransactions().filter(item => item.kindRawValue === "expense" && !item.isBalanceAdjustment && inPeriod(item, start, end))) { + spending.set(transaction.categoryName, (spending.get(transaction.categoryName) || 0) + Number(transaction.amount || 0)); + } + const budgets = allBudgets().filter(item => { + const date = new Date(item.monthStart); + const now = new Date(); + return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && Number(item.limit) > 0; + }).sort((a, b) => a.categoryName.localeCompare(b.categoryName, "ru")); + const content = budgets.length ? budgets.map(budget => { + const value = spending.get(budget.categoryName) || 0; + const ratio = Math.min(value / Math.max(Number(budget.limit), 1), 1); + const over = value > Number(budget.limit); + const item = { emoji: budget.categoryEmoji, icon: budget.categoryIcon, colorName: "indigo" }; + return `
+
${categoryBadge(item)}
${escapeHTML(budget.categoryName)}${over ? "Лимит превышен" : `Осталось ${formatMoney(Math.max(Number(budget.limit) - value, 0))}`}
+
${formatMoney(value)}из ${formatMoney(Number(budget.limit))}
+ +
`; + }).join("") : emptyState("◎", "Лимиты не настроены", "Задайте месячный лимит для одной или нескольких категорий расходов.", ``); + return `${pageHeader("Бюджеты", "Месячные лимиты по категориям расходов", ``)} +
${content}
`; +} + +function renderCategories() { + const categories = allCategories().sort((a, b) => a.name.localeCompare(b.name, "ru")); + return `${pageHeader("Категории", "Собственные категории синхронизируются со всеми устройствами", ``)} + ${categories.length ? `
${categories.map(category => `
+ ${categoryBadge(category)} +
${escapeHTML(category.name)}${category.kindRawValue === "income" ? "доход" : "расход"}
+
+ + +
+
`).join("")}
` : `
${emptyState("◆", "Своих категорий пока нет", "Создайте категорию с одной из 50 иконок или собственным эмодзи.", ``)}
`}`; +} + +function renderSettings() { + return `${pageHeader("Настройки", "Отображение, синхронизация и аккаунт")} +
+
+
+

Отображение

Настройки сохраняются только в этом браузере.

+
ВалютаИспользуется для форматирования сумм
+
+
ТемаСветлая, тёмная или системная
+
+
+
+

Данные

Все изменения отправляются на ваш Balance Server.

+
Пользовательские категории${state.categories.size} сохранено
+
СинхронизацияПоследняя: ${state.lastSyncAt ? formatDateTime(state.lastSyncAt) : "в этой сессии не выполнялась"}
+
Записи${state.transactions.size} операций · ${state.budgets.size} бюджетов
+
+
+ +
`; +} + +function buildCategoryOptions(kind, selected) { + return categoriesFor(kind).map(category => ``).join(""); +} + +function modalShell(title, body, saveLabel = "Сохранить", form = "editor-form") { + return ``; +} + +function showDialog(content) { + $("#dialog-content").innerHTML = content; + const dialog = $("#editor-dialog"); + if (!dialog.open) dialog.showModal(); +} + +function closeDialog() { + const dialog = $("#editor-dialog"); + if (dialog.open) dialog.close(); + $("#dialog-content").innerHTML = ""; +} + +function openTransactionEditor(id = null) { + const existing = id ? state.transactions.get(id) : null; + const kind = existing?.kindRawValue || "expense"; + const first = categoriesFor(kind)[0]; + const selected = existing?.categoryName || first.name; + const body = ` + + +
+ + +
+ + + + `; + showDialog(modalShell(existing ? "Редактирование операции" : "Новая операция", body, "Сохранить", "transaction-form")); +} + +function iconChoices(selected) { + return ICONS.map(([value, glyph]) => ``).join(""); +} + +function emojiChoices(selected) { + return EMOJIS.map(value => ``).join(""); +} + +function colorChoices(selected) { + return COLORS.map(value => ``).join(""); +} + +function openCategoryEditor(id = null) { + const existing = id ? state.categories.get(id) : null; + const kind = existing?.kindRawValue || "expense"; + const visual = existing?.emoji ? "emoji" : "icon"; + const body = ` + + + + + ${existing ? `` : ""} +
+

Иконка

${iconChoices(existing?.icon || "star.fill")}
+
+ +
${emojiChoices(existing?.emoji || "")}
+
+

Цвет

${colorChoices(existing?.colorName || "indigo")}
+

При изменении категории её название, иконка и цвет обновятся в связанных операциях и бюджетах.

`; + showDialog(modalShell(existing ? "Редактирование категории" : "Новая категория", body, "Сохранить", "category-form")); +} + +function openBudgetEditor() { + const categories = categoriesFor("expense"); + const rows = categories.map((category, index) => { + const budget = currentBudget(category.name); + return `
${categoryBadge(category, "small")}${escapeHTML(category.name)}
`; + }).join(""); + const body = `

Укажите месячный лимит. Пустое поле или ноль удаляет существующий бюджет.

${rows}
`; + showDialog(modalShell("Месячные лимиты", body, "Сохранить", "budget-form")); +} + +function openBalanceAdjustment() { + const current = totalBalance(); + const body = `

Расчётный баланс: ${formatMoney(current)}. Корректировка не влияет на аналитику.

+ + `; + showDialog(modalShell("Корректировка баланса", body, "Скорректировать", "adjustment-form")); +} + +async function submitTransaction(form) { + const data = new FormData(form); + const amount = Number(String(data.get("amount")).replace(",", ".")); + if (!Number.isFinite(amount) || amount <= 0) throw new Error("Введите сумму больше нуля."); + const kind = data.get("kind") === "income" ? "income" : "expense"; + const category = categoriesFor(kind).find(item => item.name === data.get("category")); + if (!category) throw new Error("Выберите категорию."); + const existing = state.transactions.get(String(data.get("id"))) || null; + const id = existing?.id || randomID(); + const value = { + amount, date: dateInputISO(String(data.get("date"))), note: String(data.get("note") || "").trim(), + categoryName: category.name, categoryIcon: category.icon, categoryEmoji: category.emoji || "", + categoryColorName: category.colorName, isBalanceAdjustment: false, kindRawValue: kind, + }; + await pushChanges([changeFor("transaction", id, transactionPayload(value))]); + closeDialog(); + toast(existing ? "Операция обновлена" : "Операция добавлена"); +} + +async function submitCategory(form) { + const data = new FormData(form); + const existing = state.categories.get(String(data.get("id"))) || null; + const name = String(data.get("name") || "").trim(); + const kind = String(existing?.kindRawValue || data.get("kind-fixed") || data.get("kind")) === "income" ? "income" : "expense"; + if (!name) throw new Error("Введите название категории."); + const duplicate = allCategories().some(item => item.id !== existing?.id && item.kindRawValue === kind && item.name.localeCompare(name, "ru", { sensitivity: "accent" }) === 0); + if (duplicate) throw new Error("Категория с таким названием уже существует."); + const visual = data.get("visual") === "emoji" ? "emoji" : "icon"; + const emoji = visual === "emoji" ? firstGrapheme(data.get("emoji") || data.get("emoji-choice")) : ""; + if (visual === "emoji" && !emoji) throw new Error("Введите или выберите эмодзи."); + const now = new Date().toISOString(); + const value = { + name, icon: String(data.get("icon") || existing?.icon || "star.fill"), emoji, + colorName: safeColor(String(data.get("color") || "indigo")), kindRawValue: kind, + createdAt: existing?.createdAt || now, + }; + const changes = [changeFor("category", existing?.id || randomID(), categoryPayload(value), false, now)]; + if (existing) { + for (const transaction of allTransactions().filter(item => item.categoryName === existing.name && item.kindRawValue === kind)) { + changes.push(changeFor("transaction", transaction.id, transactionPayload({ + ...transaction, categoryName: name, categoryIcon: value.icon, categoryEmoji: value.emoji, categoryColorName: value.colorName, + }), false, now)); + } + for (const budget of allBudgets().filter(item => item.categoryName === existing.name)) { + changes.push(changeFor("budget", budget.id, budgetPayload({ + ...budget, categoryName: name, categoryIcon: value.icon, categoryEmoji: value.emoji, + }), false, now)); + } + } + await pushChanges(changes); + closeDialog(); + toast(existing ? "Категория обновлена" : "Категория добавлена"); +} + +async function submitBudgets(form) { + const data = new FormData(form); + const categories = categoriesFor("expense"); + const now = new Date().toISOString(); + const changes = []; + categories.forEach((category, index) => { + const amount = Number(String(data.get(`budget-${index}`) || "0").replace(",", ".")); + const existing = currentBudget(category.name); + if (Number.isFinite(amount) && amount > 0) { + changes.push(changeFor("budget", existing?.id || randomID(), budgetPayload({ + categoryName: category.name, categoryIcon: category.icon, categoryEmoji: category.emoji || "", + limit: amount, monthStart: existing?.monthStart || currentMonthStartISO(), + }), false, now)); + } else if (existing) { + changes.push(changeFor("budget", existing.id, null, true, now)); + } + }); + if (changes.length) await pushChanges(changes); + closeDialog(); + toast("Бюджеты обновлены"); +} + +async function submitAdjustment(form) { + const data = new FormData(form); + const target = Number(String(data.get("target")).replace(",", ".")); + if (!Number.isFinite(target)) throw new Error("Введите фактический баланс."); + const difference = target - totalBalance(); + if (Math.abs(difference) < 0.005) throw new Error("Баланс уже соответствует указанной сумме."); + const value = { + amount: Math.abs(difference), date: new Date().toISOString(), + note: String(data.get("note") || "").trim() || "Ручная корректировка баланса", + categoryName: "Корректировка", categoryIcon: "tune", categoryEmoji: "", categoryColorName: "indigo", + isBalanceAdjustment: true, kindRawValue: difference > 0 ? "income" : "expense", + }; + await pushChanges([changeFor("transaction", randomID(), transactionPayload(value))]); + closeDialog(); + toast("Баланс скорректирован"); +} + +async function deleteTransaction(id) { + const value = state.transactions.get(id); + if (!value || !confirm(`Удалить операцию «${value.categoryName}» на сумму ${formatMoney(value.amount)}?`)) return; + await pushChanges([changeFor("transaction", id, null, true)]); + toast("Операция удалена"); +} + +async function deleteCategory(id) { + const value = state.categories.get(id); + if (!value || !confirm(`Удалить категорию «${value.name}»? Существующие операции сохранятся.`)) return; + await pushChanges([changeFor("category", id, null, true)]); + toast("Категория удалена"); +} + +function setRoute(route) { + if (!["dashboard", "transactions", "analytics", "budgets", "categories", "settings"].includes(route)) return; + state.route = route; + render(); + $("#content")?.focus({ preventScroll: true }); + window.scrollTo({ top: 0, behavior: "smooth" }); +} + +function setSyncStatus(type, text) { + const pill = $("#sync-pill"); + if (!pill) return; + pill.classList.toggle("is-syncing", type === "syncing"); + pill.classList.toggle("is-error", type === "error"); + const label = $("span:last-child", pill); + if (label) label.textContent = text; +} + +function toast(message, error = false) { + const item = document.createElement("div"); + item.className = `toast${error ? " is-error" : ""}`; + item.textContent = message; + $("#toast-region").append(item); + window.setTimeout(() => item.remove(), 4200); +} + +function showApp() { + $("#auth-view").hidden = true; + $("#app-view").hidden = false; + $("#account-email").textContent = state.user.email; + $("#account-avatar").textContent = (state.user.email || "Б")[0].toUpperCase(); + render(); +} + +function showAuth() { + state.user = null; + state.transactions.clear(); + state.categories.clear(); + state.budgets.clear(); + state.cursor = 0; + $("#app-view").hidden = true; + $("#auth-view").hidden = false; + document.title = "Balance — вход"; +} + +async function authenticate(event) { + event.preventDefault(); + const form = event.currentTarget; + const button = $("#auth-submit"); + const error = $("#auth-error"); + error.hidden = true; + button.disabled = true; + button.textContent = state.authMode === "login" ? "Входим…" : "Создаём аккаунт…"; + try { + await apiFetch(`/v1/auth/${state.authMode}`, { + method: "POST", + body: JSON.stringify({ email: form.email.value.trim(), password: form.password.value }), + }, false); + state.user = await apiFetch("/v1/me"); + showApp(); + } catch (failure) { + error.textContent = failure.message || "Не удалось войти."; + error.hidden = false; + return; + } finally { + button.disabled = false; + button.textContent = state.authMode === "login" ? "Войти" : "Создать аккаунт"; + } + try { + await fullSync(); + } catch (failure) { + toast(failure.message || "Вход выполнен, но синхронизация пока недоступна.", true); + } +} + +function setAuthMode(mode) { + state.authMode = mode === "register" ? "register" : "login"; + $$('[data-auth-mode]').forEach(button => button.classList.toggle("is-active", button.dataset.authMode === state.authMode)); + $("#auth-submit").textContent = state.authMode === "login" ? "Войти" : "Создать аккаунт"; + $("#auth-password").autocomplete = state.authMode === "login" ? "current-password" : "new-password"; + $("#auth-error").hidden = true; +} + +async function logout() { + if (!confirm("Выйти из аккаунта в этом браузере?")) return; + try { + await apiFetch("/v1/auth/logout", { method: "POST", body: JSON.stringify({ refreshToken: "" }) }, false); + } catch { + // The local session is cleared even if the server is temporarily unavailable. + } + showAuth(); +} + +async function handleAction(action, id) { + try { + if (action === "transaction-new") openTransactionEditor(); + else if (action === "transaction-edit") openTransactionEditor(id); + else if (action === "transaction-delete") await deleteTransaction(id); + else if (action === "category-new") openCategoryEditor(); + else if (action === "category-edit") openCategoryEditor(id); + else if (action === "category-delete") await deleteCategory(id); + else if (action === "budgets-edit") openBudgetEditor(); + else if (action === "balance-adjust") openBalanceAdjustment(); + else if (action === "sync") await pullChanges(false); + else if (action === "logout") await logout(); + else if (action === "dialog-close") closeDialog(); + } catch (error) { + toast(error.message || "Не удалось выполнить действие", true); + } +} + +document.addEventListener("click", event => { + const route = event.target.closest("[data-route]"); + if (route) { + event.preventDefault(); + setRoute(route.dataset.route); + return; + } + const authMode = event.target.closest("[data-auth-mode]"); + if (authMode) { + setAuthMode(authMode.dataset.authMode); + return; + } + const analytics = event.target.closest("[data-analytics-months]"); + if (analytics) { + state.analyticsMonths = Number(analytics.dataset.analyticsMonths) || 1; + render(); + return; + } + const transactionKind = event.target.closest("[data-transaction-kind]"); + if (transactionKind) { + const kind = transactionKind.dataset.transactionKind; + $("#transaction-kind").value = kind; + $$('[data-transaction-kind]').forEach(button => button.classList.toggle("is-active", button.dataset.transactionKind === kind)); + $("#transaction-category").innerHTML = buildCategoryOptions(kind, categoriesFor(kind)[0]?.name); + return; + } + const categoryVisual = event.target.closest("[data-category-visual]"); + if (categoryVisual) { + const visual = categoryVisual.dataset.categoryVisual; + $("#category-visual").value = visual; + $$('[data-category-visual]').forEach(button => button.classList.toggle("is-active", button.dataset.categoryVisual === visual)); + $("#icon-section").hidden = visual !== "icon"; + $("#emoji-section").hidden = visual !== "emoji"; + return; + } + const emojiChoice = event.target.closest('input[name="emoji-choice"]'); + if (emojiChoice && $("#category-emoji")) $("#category-emoji").value = emojiChoice.value; + const action = event.target.closest("[data-action]"); + if (action) void handleAction(action.dataset.action, action.dataset.id); +}); + +document.addEventListener("input", event => { + if (event.target.id === "transaction-search") { + state.transactionQuery = event.target.value; + const list = $("#transaction-list"); + if (list) list.innerHTML = transactionListHTML(); + } + if (event.target.id === "category-emoji") { + $$('input[name="emoji-choice"]').forEach(input => { input.checked = input.value === event.target.value; }); + } +}); + +document.addEventListener("change", event => { + if (event.target.id === "transaction-filter") { + state.transactionKind = event.target.value; + const list = $("#transaction-list"); + if (list) list.innerHTML = transactionListHTML(); + } else if (event.target.id === "currency-setting") { + preferences.currency = event.target.value; + savePreferences(); + render(); + } else if (event.target.id === "theme-setting") { + preferences.theme = event.target.value; + savePreferences(); + applyTheme(); + render(); + } else if (event.target.name === "emoji-choice" && $("#category-emoji")) { + $("#category-emoji").value = event.target.value; + } +}); + +document.addEventListener("submit", event => { + if (event.target.id === "auth-form") return; + event.preventDefault(); + const form = event.target; + const submit = $('button[type="submit"]', form); + if (submit) submit.disabled = true; + const actions = { + "transaction-form": submitTransaction, + "category-form": submitCategory, + "budget-form": submitBudgets, + "adjustment-form": submitAdjustment, + }; + const action = actions[form.id]; + if (!action) return; + void action(form).catch(error => { + toast(error.message || "Проверьте введённые данные", true); + if (submit) submit.disabled = false; + }); +}); + +$("#auth-form").addEventListener("submit", authenticate); +$("#editor-dialog").addEventListener("click", event => { + if (event.target === event.currentTarget) closeDialog(); +}); +mediaDark.addEventListener("change", () => { if (preferences.theme === "system") applyTheme(); }); +document.addEventListener("visibilitychange", () => { + if (!document.hidden && state.user && !state.syncing) void pullChanges(true).catch(() => {}); +}); +window.setInterval(() => { + if (state.user && !document.hidden && !state.syncing) void pullChanges(true).catch(() => {}); +}, 30_000); + +async function boot() { + applyTheme(); + if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker.js").catch(() => {}); + try { + state.user = await apiFetch("/v1/me"); + } catch { + showAuth(); + return; + } + showApp(); + try { + await fullSync(); + } catch (failure) { + toast(failure.message || "Не удалось загрузить данные с сервера.", true); + } +} + +void boot(); diff --git a/internal/httpapi/web/icon.svg b/internal/httpapi/web/icon.svg new file mode 100644 index 0000000..2c85ef5 --- /dev/null +++ b/internal/httpapi/web/icon.svg @@ -0,0 +1,19 @@ + + Balance + + + + + + + + + + + + + + + + + diff --git a/internal/httpapi/web/index.html b/internal/httpapi/web/index.html new file mode 100644 index 0000000..e439725 --- /dev/null +++ b/internal/httpapi/web/index.html @@ -0,0 +1,106 @@ + + + + + + + + Balance + + + + + +
+ +
+
+
+ +
+ Balance + Ваши финансы. Ваш сервер. +
+
+
+

SELF-HOSTED

+

Финансы под вашим контролем

+

Войдите в аккаунт этого сервера. Операции синхронизируются с iPhone, Apple Watch, Mac и Android.

+
+
+ + +
+
+ + + + +
+

Сессия хранится в защищённых HttpOnly-cookie и недоступна JavaScript.

+
+
+ + + + +
+
+ + + + + diff --git a/internal/httpapi/web/manifest.webmanifest b/internal/httpapi/web/manifest.webmanifest new file mode 100644 index 0000000..bf18318 --- /dev/null +++ b/internal/httpapi/web/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Balance — личные финансы", + "short_name": "Balance", + "description": "Self-hosted приложение для управления личными финансами", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f4f5f8", + "theme_color": "#5b5bd6", + "icons": [ + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" } + ] +} diff --git a/internal/httpapi/web/service-worker.js b/internal/httpapi/web/service-worker.js new file mode 100644 index 0000000..db3033f --- /dev/null +++ b/internal/httpapi/web/service-worker.js @@ -0,0 +1,22 @@ +const CACHE = "balance-shell-v2"; +const SHELL = ["/", "/styles.css", "/app.js", "/icon.svg", "/manifest.webmanifest"]; + +self.addEventListener("install", event => { + event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL))); + self.skipWaiting(); +}); + +self.addEventListener("activate", event => { + event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key))))); + self.clients.claim(); +}); + +self.addEventListener("fetch", event => { + const url = new URL(event.request.url); + if (event.request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/v1/") || url.pathname === "/health") return; + event.respondWith(fetch(event.request).then(response => { + const copy = response.clone(); + caches.open(CACHE).then(cache => cache.put(event.request, copy)); + return response; + }).catch(() => caches.match(event.request).then(response => response || caches.match("/")))); +}); diff --git a/internal/httpapi/web/styles.css b/internal/httpapi/web/styles.css new file mode 100644 index 0000000..54af28d --- /dev/null +++ b/internal/httpapi/web/styles.css @@ -0,0 +1,487 @@ +:root { + color-scheme: light; + --bg: #f4f5f8; + --surface: #ffffff; + --surface-soft: #f8f8fb; + --surface-strong: #edeef3; + --text: #17171c; + --muted: #70717c; + --border: #e3e4ea; + --primary: #5b5bd6; + --primary-strong: #4747bd; + --primary-soft: #ececfe; + --green: #16a36a; + --green-soft: #e8f7f0; + --red: #e05252; + --red-soft: #fcecec; + --orange: #f28c38; + --shadow: 0 16px 44px rgb(31 35 55 / 8%); + --radius: 20px; + --sidebar: 252px; + font-family: Inter, ui-rounded, "SF Pro Rounded", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #111116; + --surface: #1b1b21; + --surface-soft: #222229; + --surface-strong: #2a2a32; + --text: #f5f5f7; + --muted: #a6a6b0; + --border: #303039; + --primary: #8888f5; + --primary-strong: #9d9dff; + --primary-soft: #29294b; + --green-soft: #15392b; + --red-soft: #422326; + --shadow: 0 18px 50px rgb(0 0 0 / 28%); +} + +* { box-sizing: border-box; } +html { min-height: 100%; background: var(--bg); } +body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--bg); color: var(--text); } +button, input, select, textarea { font: inherit; } +button { color: inherit; } +button, select { cursor: pointer; } +img { display: block; } +[hidden] { display: none !important; } + +:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 42%, transparent); outline-offset: 2px; } + +.auth-layout { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px 18px; + background: + radial-gradient(circle at 12% 10%, color-mix(in srgb, var(--primary) 16%, transparent), transparent 32%), + radial-gradient(circle at 88% 88%, color-mix(in srgb, var(--green) 10%, transparent), transparent 30%), + var(--bg); +} + +.auth-card { + width: min(100%, 480px); + padding: 30px; + border: 1px solid var(--border); + border-radius: 30px; + background: color-mix(in srgb, var(--surface) 94%, transparent); + box-shadow: var(--shadow); +} + +.brand { display: flex; align-items: center; gap: 11px; } +.brand-auth { margin-bottom: 32px; } +.brand-name { display: block; font-size: 20px; line-height: 1.05; font-weight: 800; letter-spacing: -.4px; } +.brand-caption { display: block; margin-top: 4px; color: var(--muted); font-size: 12px; } +.auth-copy h1 { margin: 4px 0 12px; font-size: clamp(30px, 7vw, 42px); line-height: 1.04; letter-spacing: -1.6px; } +.auth-copy > p:last-child { margin: 0 0 24px; color: var(--muted); line-height: 1.55; } +.eyebrow { margin: 0; color: var(--primary); font-size: 11px; font-weight: 800; letter-spacing: 1.6px; } +.security-note { display: flex; gap: 8px; margin: 20px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; } + +.stack { display: grid; gap: 14px; } +.field { display: grid; gap: 7px; } +.field > span, .field-label { color: var(--muted); font-size: 12px; font-weight: 700; } +.field input, .field select, .field textarea, .select-control, .search-control { + width: 100%; + min-height: 46px; + padding: 11px 13px; + border: 1px solid var(--border); + border-radius: 13px; + background: var(--surface-soft); + color: var(--text); + outline: none; + transition: border-color .16s, box-shadow .16s, background .16s; +} +.field textarea { min-height: 88px; resize: vertical; } +.field input:focus, .field select:focus, .field textarea:focus, .select-control:focus, .search-control:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent); + background: var(--surface); +} +.form-error { margin: -2px 0 0; padding: 10px 12px; border-radius: 11px; background: var(--red-soft); color: var(--red); font-size: 13px; } + +.segmented { display: flex; gap: 4px; padding: 4px; border-radius: 13px; background: var(--surface-strong); } +.segmented button { flex: 1; min-height: 38px; border: 0; border-radius: 10px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 700; } +.segmented button.is-active { background: var(--surface); color: var(--text); box-shadow: 0 2px 10px rgb(0 0 0 / 7%); } +.auth-switch { margin-bottom: 17px; } + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + padding: 9px 15px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--text); + font-weight: 700; + font-size: 13px; + text-decoration: none; + transition: transform .14s, background .14s, border-color .14s; +} +.button:hover { background: var(--surface-soft); border-color: color-mix(in srgb, var(--primary) 30%, var(--border)); } +.button:active { transform: scale(.98); } +.button:disabled { cursor: not-allowed; opacity: .55; } +.button-large { min-height: 48px; font-size: 14px; } +.button-primary { border-color: var(--primary); background: var(--primary); color: white; } +.button-primary:hover { border-color: var(--primary-strong); background: var(--primary-strong); } +.button-danger { border-color: color-mix(in srgb, var(--red) 25%, var(--border)); color: var(--red); } +.button-ghost { border-color: transparent; background: transparent; color: var(--muted); } +.button-row { display: flex; flex-wrap: wrap; gap: 9px; } + +.app-shell { min-height: 100vh; } +.sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 20; + display: flex; + width: var(--sidebar); + flex-direction: column; + padding: 24px 16px 18px; + border-right: 1px solid var(--border); + background: var(--surface); +} +.sidebar-brand { padding: 0 8px 24px; } +.side-nav { display: grid; gap: 5px; } +.side-nav button { + display: flex; + align-items: center; + gap: 12px; + min-height: 45px; + padding: 8px 13px; + border: 0; + border-radius: 13px; + background: transparent; + color: var(--muted); + text-align: left; + font-weight: 700; + font-size: 14px; +} +.side-nav button span { display: grid; width: 22px; place-items: center; font-size: 20px; } +.side-nav button:hover { background: var(--surface-soft); color: var(--text); } +.side-nav button.is-active { background: var(--primary-soft); color: var(--primary-strong); } +.sidebar-footer { display: grid; gap: 14px; margin-top: auto; } +.sync-pill { display: flex; align-items: center; gap: 8px; padding: 0 9px; color: var(--muted); font-size: 11px; } +.sync-pill > span:first-child { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px var(--green-soft); } +.sync-pill.is-syncing > span:first-child { background: var(--orange); animation: pulse 1s infinite; } +.sync-pill.is-error > span:first-child { background: var(--red); box-shadow: 0 0 0 4px var(--red-soft); } +@keyframes pulse { 50% { opacity: .35; } } +.account-mini { display: flex; align-items: center; gap: 10px; padding: 12px 9px 0; border-top: 1px solid var(--border); overflow: hidden; } +.account-mini > div:last-child { min-width: 0; } +.account-mini strong, .account-mini span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.account-mini strong { font-size: 12px; } +.account-mini span { margin-top: 3px; color: var(--muted); font-size: 10px; } +.avatar { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border-radius: 11px; background: var(--primary-soft); color: var(--primary-strong); font-weight: 800; } + +.workspace { min-height: 100vh; margin-left: var(--sidebar); } +.content { width: min(1180px, 100%); margin: 0 auto; padding: 38px 36px 60px; } +.mobile-header, .bottom-nav { display: none; } +.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 25px; } +.page-header h1 { margin: 2px 0 0; font-size: clamp(27px, 4vw, 38px); line-height: 1.08; letter-spacing: -1.25px; } +.page-header p { margin: 7px 0 0; color: var(--muted); font-size: 13px; } +.page-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 9px; } + +.grid { display: grid; gap: 16px; } +.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.dashboard-grid { grid-template-columns: minmax(0, 1.45fr) minmax(300px, .9fr); align-items: start; } +.span-2 { grid-column: span 2; } +.card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 8px 30px rgb(31 35 55 / 4%); } +.card-body { padding: 20px; } +.card-header { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 19px 20px 0; } +.card-header h2, .card-header h3 { margin: 0; font-size: 16px; letter-spacing: -.25px; } +.card-header + .card-body { padding-top: 15px; } +.hero-balance { + position: relative; + min-height: 230px; + overflow: hidden; + padding: 26px; + border: 0; + background: linear-gradient(135deg, #6868e9, #4747b9); + color: white; + box-shadow: 0 20px 42px rgb(70 70 185 / 23%); +} +.hero-balance::after { content: ""; position: absolute; right: -80px; bottom: -125px; width: 290px; height: 290px; border: 44px solid rgb(255 255 255 / 8%); border-radius: 50%; } +.hero-balance .hero-label { position: relative; z-index: 1; margin: 0; color: rgb(255 255 255 / 72%); font-size: 13px; font-weight: 700; } +.hero-value { position: relative; z-index: 1; margin: 13px 0 23px; font-size: clamp(33px, 5vw, 50px); font-weight: 850; letter-spacing: -2px; } +.hero-meta { position: relative; z-index: 1; display: flex; flex-wrap: wrap; gap: 10px 24px; } +.hero-meta div span, .metric-card span { display: block; font-size: 11px; font-weight: 700; } +.hero-meta div span { color: rgb(255 255 255 / 66%); } +.hero-meta div strong { display: block; margin-top: 5px; font-size: 15px; } +.hero-balance .button { position: relative; z-index: 1; margin-top: 22px; border-color: rgb(255 255 255 / 28%); background: rgb(255 255 255 / 13%); color: white; } +.hero-balance .button:hover { background: rgb(255 255 255 / 20%); } + +.metric-card { min-height: 118px; padding: 17px; } +.metric-card span { color: var(--muted); } +.metric-card strong { display: block; margin-top: 12px; font-size: 23px; letter-spacing: -.8px; } +.metric-card .metric-foot { margin-top: 6px; color: var(--muted); font-size: 11px; } +.metric-card.is-income strong { color: var(--green); } +.metric-card.is-expense strong { color: var(--red); } +.metric-card.is-primary strong { color: var(--primary); } + +.list { display: grid; } +.list-row { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 13px 0; border-bottom: 1px solid var(--border); } +.list-row:first-child { padding-top: 3px; } +.list-row:last-child { padding-bottom: 3px; border-bottom: 0; } +.row-main { min-width: 0; flex: 1; } +.row-main strong, .row-main span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row-main strong { font-size: 13px; } +.row-main span { margin-top: 4px; color: var(--muted); font-size: 11px; } +.row-value { flex: 0 0 auto; text-align: right; } +.row-value strong, .row-value span { display: block; } +.row-value strong { font-size: 13px; } +.row-value span { margin-top: 4px; color: var(--muted); font-size: 10px; } +.amount-income { color: var(--green); } +.amount-expense { color: var(--text); } +.row-actions { display: flex; gap: 3px; opacity: .25; transition: opacity .15s; } +.list-row:hover .row-actions { opacity: 1; } +.icon-button { display: grid; width: 36px; height: 36px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 10px; background: transparent; color: var(--muted); } +.icon-button:hover { background: var(--surface-strong); color: var(--text); } +.icon-button.is-danger:hover { background: var(--red-soft); color: var(--red); } + +.category-badge { display: grid; width: 43px; height: 43px; flex: 0 0 auto; place-items: center; border-radius: 14px; font-size: 20px; font-weight: 800; } +.category-badge.small { width: 34px; height: 34px; border-radius: 11px; font-size: 16px; } +.category-badge.large { width: 58px; height: 58px; border-radius: 18px; font-size: 26px; } +.category-badge .symbol { font-size: .88em; } +.color-red { color: #e05252; background: #e0525222; } .color-orange { color: #f28c38; background: #f28c3822; } +.color-yellow { color: #e0ac24; background: #e0ac2422; } .color-green { color: #16a36a; background: #16a36a22; } +.color-mint { color: #31b99a; background: #31b99a22; } .color-teal { color: #159eaf; background: #159eaf22; } +.color-cyan { color: #209bcc; background: #209bcc22; } .color-blue { color: #3979e9; background: #3979e922; } +.color-indigo { color: #5b5bd6; background: #5b5bd622; } .color-purple { color: #8b5cf6; background: #8b5cf622; } +.color-pink { color: #e35c9a; background: #e35c9a22; } .color-brown { color: #9b6a4a; background: #9b6a4a22; } +.color-gray { color: #7a7a85; background: #7a7a8522; } .color-coral { color: #fa6161; background: #fa616122; } +.color-peach { color: #ff9461; background: #ff946122; } .color-gold { color: #e6a314; background: #e6a31422; } +.color-lime { color: #85c729; background: #85c72922; } .color-forest { color: #1a7a4d; background: #1a7a4d22; } +.color-turquoise { color: #0daba1; background: #0daba122; } .color-sky { color: #2e9ee8; background: #2e9ee822; } +.color-navy { color: #365ca8; background: #1f408522; } .color-lavender { color: #8c66d9; background: #8c66d922; } +.color-magenta { color: #d1298c; background: #d1298c22; } .color-slate { color: #66758e; background: #4d5c7322; } + +.toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; } +.toolbar .search-wrap { position: relative; min-width: 220px; flex: 1; } +.toolbar .search-wrap::before { content: "⌕"; position: absolute; top: 50%; left: 13px; transform: translateY(-50%); color: var(--muted); } +.toolbar .search-control { padding-left: 38px; } +.toolbar .select-control { width: auto; min-width: 150px; } +.operations-card .card-body { padding-top: 9px; } +.operations-card .list-row { padding: 14px 0; } + +.empty-state { display: grid; min-height: 210px; place-items: center; padding: 35px; text-align: center; } +.empty-state .empty-icon { display: grid; width: 62px; height: 62px; place-items: center; margin: 0 auto 14px; border-radius: 20px; background: var(--primary-soft); color: var(--primary); font-size: 27px; } +.empty-state h3 { margin: 0; font-size: 16px; } +.empty-state p { max-width: 390px; margin: 8px auto 0; color: var(--muted); font-size: 13px; line-height: 1.5; } +.empty-state .button { margin-top: 17px; } + +.analytics-page { display: grid; row-gap: 24px; padding-bottom: 8px; } +.analytics-page > .page-header { margin-bottom: 0; } +.analytics-layout { grid-template-columns: minmax(0, 1.25fr) minmax(310px, .75fr); align-items: start; gap: 20px; } +.analytics-layout > * { min-width: 0; } +.analytics-metrics { gap: 18px; } +.analytics-metrics .metric-card { display: flex; min-height: 132px; flex-direction: column; justify-content: center; padding: 20px; } +.period-switch { width: fit-content; margin-bottom: 0; } +.period-switch button { min-width: 82px; } +.breakdown { display: grid; gap: 15px; } +.breakdown-row { display: grid; grid-template-columns: minmax(110px, 1fr) minmax(150px, 2fr) auto; align-items: center; gap: 12px; } +.breakdown-name { display: flex; min-width: 0; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; } +.breakdown-name span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +progress { width: 100%; height: 8px; overflow: hidden; border: 0; border-radius: 999px; background: var(--surface-strong); color: var(--primary); } +progress::-webkit-progress-bar { border-radius: 999px; background: var(--surface-strong); } +progress::-webkit-progress-value { border-radius: 999px; background: currentColor; } +progress::-moz-progress-bar { border-radius: 999px; background: currentColor; } +.progress-red { color: var(--red); } .progress-orange { color: var(--orange); } .progress-yellow { color: #e0ac24; } +.progress-green { color: var(--green); } .progress-mint { color: #31b99a; } .progress-teal { color: #159eaf; } +.progress-cyan { color: #209bcc; } .progress-blue { color: #3979e9; } .progress-indigo { color: var(--primary); } +.progress-purple { color: #8b5cf6; } .progress-pink { color: #e35c9a; } .progress-brown { color: #9b6a4a; } +.progress-gray { color: #7a7a85; } .progress-coral { color: #fa6161; } .progress-peach { color: #ff9461; } +.progress-gold { color: #e6a314; } .progress-lime { color: #85c729; } .progress-forest { color: #1a7a4d; } +.progress-turquoise { color: #0daba1; } .progress-sky { color: #2e9ee8; } .progress-navy { color: #365ca8; } +.progress-lavender { color: #8c66d9; } .progress-magenta { color: #d1298c; } .progress-slate { color: #66758e; } +.breakdown-value { min-width: 85px; text-align: right; font-size: 12px; font-weight: 750; } +.insight-card { padding: 20px; background: linear-gradient(145deg, var(--primary-soft), var(--surface)); } +.insight-card .insight-icon { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 13px; background: var(--primary); color: white; font-size: 20px; } +.insight-card h3 { margin: 16px 0 8px; font-size: 16px; } +.insight-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.55; } +.analytics-breakdown-card .card-body { padding-top: 18px; padding-bottom: 22px; } + +.budget-list { display: grid; gap: 12px; } +.budget-item { padding: 17px; } +.budget-top { display: flex; align-items: center; gap: 11px; } +.budget-copy { min-width: 0; flex: 1; } +.budget-copy strong, .budget-copy span { display: block; } +.budget-copy strong { font-size: 13px; } +.budget-copy span { margin-top: 4px; color: var(--muted); font-size: 11px; } +.budget-values { text-align: right; } +.budget-values strong, .budget-values span { display: block; } +.budget-values strong { font-size: 13px; } +.budget-values span { margin-top: 4px; color: var(--muted); font-size: 10px; } +.budget-item progress { margin-top: 13px; } +.budget-item.is-over { border-color: color-mix(in srgb, var(--red) 35%, var(--border)); } +.budget-item.is-over progress { color: var(--red); } + +.category-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.category-card { display: flex; align-items: center; gap: 12px; padding: 15px; } +.category-card .row-main span { text-transform: capitalize; } +.category-card .row-actions { margin-left: auto; } + +.settings-layout { grid-template-columns: minmax(0, 1.1fr) minmax(300px, .7fr); align-items: start; } +.settings-section { padding: 20px; } +.settings-section h2 { margin: 0 0 4px; font-size: 16px; } +.settings-section > p { margin: 0 0 18px; color: var(--muted); font-size: 12px; line-height: 1.45; } +.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 0; border-top: 1px solid var(--border); } +.settings-row:first-of-type { border-top: 0; } +.settings-row div:first-child { min-width: 0; } +.settings-row strong, .settings-row span { display: block; } +.settings-row strong { font-size: 13px; } +.settings-row span { margin-top: 4px; color: var(--muted); font-size: 11px; } +.settings-row .select-control { width: 150px; min-height: 40px; } +.account-card { overflow: hidden; } +.account-cover { height: 88px; background: linear-gradient(135deg, #7171eb, #4444b4); } +.account-content { position: relative; padding: 0 20px 20px; } +.account-content .avatar { width: 58px; height: 58px; margin-top: -29px; border: 5px solid var(--surface); border-radius: 19px; background: var(--primary-soft); font-size: 20px; } +.account-content h3 { margin: 13px 0 4px; font-size: 16px; overflow-wrap: anywhere; } +.account-content p { margin: 0 0 17px; color: var(--muted); font-size: 12px; } +.account-content .button { width: 100%; } +.status-line { display: flex; align-items: center; gap: 9px; margin: 14px 0; padding: 11px; border-radius: 12px; background: var(--surface-soft); color: var(--muted); font-size: 11px; } +.status-line span:first-child { width: 8px; height: 8px; border-radius: 50%; background: var(--green); } + +.modal { + width: min(620px, calc(100% - 28px)); + max-height: min(88vh, 820px); + padding: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 24px; + background: var(--surface); + color: var(--text); + box-shadow: 0 26px 90px rgb(0 0 0 / 30%); +} +.modal::backdrop { background: rgb(10 10 18 / 48%); backdrop-filter: blur(5px); } +.modal-shell { display: flex; max-height: min(88vh, 820px); flex-direction: column; } +.modal-header { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 20px 22px 15px; border-bottom: 1px solid var(--border); } +.modal-header h2 { margin: 0; font-size: 19px; letter-spacing: -.4px; } +.modal-body { display: grid; gap: 15px; padding: 20px 22px; overflow-y: auto; } +.modal-footer { display: flex; justify-content: flex-end; gap: 9px; padding: 14px 22px 19px; border-top: 1px solid var(--border); background: var(--surface); } +.kind-switch { width: 100%; } +.kind-switch button { min-height: 40px; } +.choice-grid { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 7px; } +.choice-grid label { position: relative; } +.choice-grid input { position: absolute; opacity: 0; pointer-events: none; } +.choice-option { display: grid; aspect-ratio: 1; place-items: center; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-soft); font-size: 18px; transition: border-color .14s, transform .14s; } +.choice-grid label:hover .choice-option { transform: translateY(-1px); border-color: var(--primary); } +.choice-grid input:checked + .choice-option { border: 2px solid var(--primary); background: var(--primary-soft); } +.color-grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 8px; } +.color-grid label { position: relative; } +.color-grid input { position: absolute; opacity: 0; pointer-events: none; } +.color-swatch { display: block; aspect-ratio: 1; padding: 4px; border: 2px solid transparent; border-radius: 50%; background-clip: content-box; } +.color-grid input:checked + .color-swatch { border-color: var(--text); } +.swatch-red { background-color: #e05252; } .swatch-orange { background-color: #f28c38; } .swatch-yellow { background-color: #e0ac24; } +.swatch-green { background-color: #16a36a; } .swatch-mint { background-color: #31b99a; } .swatch-teal { background-color: #159eaf; } +.swatch-cyan { background-color: #209bcc; } .swatch-blue { background-color: #3979e9; } .swatch-indigo { background-color: #5b5bd6; } +.swatch-purple { background-color: #8b5cf6; } .swatch-pink { background-color: #e35c9a; } .swatch-brown { background-color: #9b6a4a; } +.swatch-gray { background-color: #7a7a85; } .swatch-coral { background-color: #fa6161; } .swatch-peach { background-color: #ff9461; } +.swatch-gold { background-color: #e6a314; } .swatch-lime { background-color: #85c729; } .swatch-forest { background-color: #1a7a4d; } +.swatch-turquoise { background-color: #0daba1; } .swatch-sky { background-color: #2e9ee8; } .swatch-navy { background-color: #1f4085; } +.swatch-lavender { background-color: #8c66d9; } .swatch-magenta { background-color: #d1298c; } .swatch-slate { background-color: #4d5c73; } +.helper-text { margin: -7px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; } +.budget-edit-list { display: grid; gap: 9px; } +.budget-edit-row { display: grid; grid-template-columns: auto minmax(0, 1fr) 135px; align-items: center; gap: 10px; } +.budget-edit-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } +.budget-edit-row input { min-width: 0; } + +.toast-region { position: fixed; top: 18px; right: 18px; z-index: 100; display: grid; width: min(380px, calc(100% - 36px)); gap: 8px; pointer-events: none; } +.toast { padding: 12px 14px; border: 1px solid var(--border); border-radius: 13px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); font-size: 12px; animation: toast-in .2s ease-out; } +.toast.is-error { border-color: color-mix(in srgb, var(--red) 40%, var(--border)); color: var(--red); } +@keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } } + +@media (max-width: 1040px) { + .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-grid, .analytics-layout, .settings-layout { grid-template-columns: 1fr; } + .category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 760px) { + :root { --radius: 17px; } + .sidebar { display: none; } + .workspace { margin-left: 0; } + .mobile-header { + position: sticky; + top: 0; + z-index: 16; + display: flex; + height: 62px; + align-items: center; + justify-content: space-between; + padding: max(10px, env(safe-area-inset-top)) 17px 8px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 91%, transparent); + backdrop-filter: blur(18px); + } + .mobile-header .brand-name { font-size: 18px; } + .content { padding: 23px 15px calc(92px + env(safe-area-inset-bottom)); } + .bottom-nav { + position: fixed; + inset: auto 0 0; + z-index: 18; + display: grid; + grid-template-columns: repeat(5, 1fr); + padding: 7px 5px calc(7px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 92%, transparent); + backdrop-filter: blur(18px); + } + .bottom-nav button { display: grid; place-items: center; gap: 2px; min-height: 43px; border: 0; background: transparent; color: var(--muted); } + .bottom-nav button span { font-size: 20px; line-height: 1; } + .bottom-nav button small { font-size: 9px; font-weight: 700; } + .bottom-nav button.is-active { color: var(--primary); } + .page-header { align-items: flex-start; margin-bottom: 18px; } + .page-header h1 { font-size: 29px; } + .page-header p { font-size: 12px; } + .page-actions .button:not(.button-primary) { display: none; } + .grid-2, .grid-3, .grid-4, .category-grid { grid-template-columns: 1fr; } + .metric-grid-mobile { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .metric-card { min-height: 104px; } + .analytics-page { row-gap: 18px; padding-bottom: 4px; } + .analytics-page > .page-header { margin-bottom: 2px; } + .analytics-metrics { gap: 12px; } + .analytics-metrics .metric-card { min-height: 112px; padding: 17px; } + .analytics-layout { gap: 16px; } + .period-switch { width: 100%; } + .hero-balance { min-height: 220px; padding: 23px; } + .hero-value { font-size: 36px; } + .toolbar { gap: 8px; } + .toolbar .search-wrap { flex-basis: 100%; } + .toolbar .select-control { min-width: 0; flex: 1; } + .row-actions { opacity: 1; } + .breakdown-row { grid-template-columns: minmax(100px, 1fr) minmax(90px, 1.2fr); } + .breakdown-value { grid-column: 2; margin-top: -7px; text-align: left; color: var(--muted); } + .choice-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } + .color-grid { grid-template-columns: repeat(8, 1fr); } + .modal { width: 100%; max-width: none; max-height: calc(100vh - 32px); margin: auto 0 0; border-radius: 24px 24px 0 0; } + .modal-shell { max-height: calc(100vh - 32px); } + .modal-header, .modal-body, .modal-footer { padding-left: 17px; padding-right: 17px; } + .budget-edit-row { grid-template-columns: auto minmax(0, 1fr) 105px; } +} + +@media (max-width: 430px) { + .auth-card { padding: 23px 19px; border-radius: 24px; } + .auth-copy h1 { font-size: 32px; } + .page-actions .button-primary { min-width: 42px; padding-inline: 12px; font-size: 0; } + .page-actions .button-primary::before { content: "+"; font-size: 21px; } + .card-body { padding: 17px; } + .card-header { padding: 17px 17px 0; } + .metric-grid-mobile { gap: 10px; } + .analytics-page { row-gap: 16px; } + .analytics-metrics { gap: 10px; } + .metric-card { padding: 14px; } + .analytics-metrics .metric-card { min-height: 108px; padding: 15px; } + .metric-card strong { font-size: 19px; } + .hero-meta { gap: 10px 18px; } + .choice-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } + .color-grid { grid-template-columns: repeat(6, 1fr); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..5d79f8d --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,432 @@ +use argon2::{Algorithm, Argon2, Params, Version}; +use base64::{ + Engine, alphabet, + engine::{ + DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig, + general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD}, + }, +}; +use chrono::{DateTime, TimeDelta, Utc}; +use hmac::{Hmac, Mac}; +use rand::{RngCore, rngs::OsRng}; +use serde::{ + Deserialize, Deserializer, Serialize, + de::{MapAccess, Visitor}, +}; +use sha2::{Digest, Sha256}; + +fn random_bytes(count: usize) -> Result, String> { + let mut bytes = vec![0; count]; + OsRng + .try_fill_bytes(&mut bytes) + .map_err(|_| "could not generate random bytes".to_string())?; + Ok(bytes) +} + +pub fn random_token(count: usize) -> Result { + Ok(URL_SAFE_NO_PAD.encode(random_bytes(count)?)) +} + +pub fn identifier() -> Result { + Ok(hex(&random_bytes(16)?)) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +pub fn token_hash(token: &str) -> String { + hex(&Sha256::digest(token.as_bytes())) +} + +// Go's Raw{Std,URL}Encoding ignores CR/LF and permits nonzero unused bits. +// Keep signatures canonical: this decoder is only for password data/payloads. +fn decode_go_base64(value: &str, url_safe: bool) -> Result, base64::DecodeError> { + let config = GeneralPurposeConfig::new() + .with_decode_padding_mode(DecodePaddingMode::RequireNone) + .with_decode_allow_trailing_bits(true); + let engine = GeneralPurpose::new( + if url_safe { + &alphabet::URL_SAFE + } else { + &alphabet::STANDARD + }, + config, + ); + let cleaned: Vec = value + .bytes() + .filter(|byte| !matches!(byte, b'\r' | b'\n')) + .collect(); + engine.decode(cleaned) +} + +pub fn hash_password(password: &str) -> Result { + if password.len() < 8 { + return Err("password must contain at least 8 characters".into()); + } + if password.len() > 1024 { + return Err("password is too long".into()); + } + let salt = random_bytes(16)?; + let mut hash = [0; 32]; + let params = Params::new(64 * 1024, 3, 2, Some(32)).map_err(|error| error.to_string())?; + Argon2::new(Algorithm::Argon2id, Version::V0x13, params) + .hash_password_into(password.as_bytes(), &salt, &mut hash) + .map_err(|error| error.to_string())?; + Ok(format!( + "$argon2id$v=19$m=65536,t=3,p=2${}${}", + STANDARD_NO_PAD.encode(salt), + STANDARD_NO_PAD.encode(hash) + )) +} + +pub fn verify_password(encoded: &str, password: &str) -> bool { + let parts: Vec<_> = encoded.split('$').collect(); + if parts.len() != 6 || parts[1] != "argon2id" || parts[2] != "v=19" { + return false; + } + let Some((memory, iterations, threads)) = password_parameters(parts[3]) else { + return false; + }; + if memory > 256 * 1024 || iterations > 10 || threads > 16 { + return false; + } + let (Ok(salt), Ok(expected)) = ( + decode_go_base64(parts[4], false), + decode_go_base64(parts[5], false), + ) else { + return false; + }; + if expected.is_empty() { + return false; + } + let Ok(params) = Params::new(memory, iterations, threads, Some(expected.len())) else { + return false; + }; + let mut actual = vec![0; expected.len()]; + if Argon2::new(Algorithm::Argon2id, Version::V0x13, params) + .hash_password_into(password.as_bytes(), &salt, &mut actual) + .is_err() + { + return false; + } + // Use the MAC crate's constant-time verification primitive for equality. + let mut expected_mac = Hmac::::new_from_slice(b"balance password comparison") + .expect("HMAC accepts any key size"); + expected_mac.update(&expected); + let expected_tag = expected_mac.finalize().into_bytes(); + let mut actual_mac = Hmac::::new_from_slice(b"balance password comparison") + .expect("HMAC accepts any key size"); + actual_mac.update(&actual); + actual_mac.verify_slice(&expected_tag).is_ok() +} + +fn password_parameters(mut text: &str) -> Option<(u32, u32, u32)> { + fn scan(text: &mut &str, prefix: &str) -> Option { + *text = text.strip_prefix(prefix)?; + *text = text.trim_start_matches(|c: char| c.is_whitespace() && c != '\n'); + let digits = *text; + let end = digits + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(digits.len()); + let value = digits[..end].parse().ok()?; + *text = &digits[end..]; + Some(value) + } + let memory = scan(&mut text, "m=")?; + let iterations = scan(&mut text, ",t=")?; + let threads = scan(&mut text, ",p=")?; + // fmt.Sscanf stops after its final conversion and accepts a trailing suffix. + if threads > u8::MAX as u32 { + return None; + } + Some((memory, iterations, threads)) +} + +#[derive(Clone, Default, Debug, Serialize)] +pub struct Claims { + #[serde(rename = "sub")] + pub subject: String, + pub email: String, + #[serde(rename = "iss")] + pub issuer: String, + #[serde(rename = "iat")] + pub issued: i64, + #[serde(rename = "exp")] + pub expires: i64, + #[serde(rename = "jti")] + pub id: String, +} + +impl<'de> Deserialize<'de> for Claims { + fn deserialize>(deserializer: D) -> Result { + struct ClaimsVisitor; + impl<'de> Visitor<'de> for ClaimsVisitor { + type Value = Claims; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JWT claims object") + } + + fn visit_unit(self) -> Result { + Ok(Claims::default()) + } + + fn visit_map>(self, mut map: M) -> Result { + let mut claims = Claims::default(); + while let Some(key) = map.next_key::()? { + // encoding/json matches tagged struct fields without case, + // and applies duplicate fields in order. Null leaves scalar + // fields unchanged, including a previously decoded value. + match key.to_ascii_lowercase().replace('ſ', "s").as_str() { + "sub" => { + if let Some(value) = map.next_value::>()? { + claims.subject = value; + } + } + "email" => { + if let Some(value) = map.next_value::>()? { + claims.email = value; + } + } + "iss" => { + if let Some(value) = map.next_value::>()? { + claims.issuer = value; + } + } + "iat" => { + if let Some(value) = map.next_value::>()? { + claims.issued = value; + } + } + "exp" => { + if let Some(value) = map.next_value::>()? { + claims.expires = value; + } + } + "jti" => { + if let Some(value) = map.next_value::>()? { + claims.id = value; + } + } + _ => { + map.next_value::()?; + } + } + } + Ok(claims) + } + } + deserializer.deserialize_any(ClaimsVisitor) + } +} + +#[derive(Clone)] +pub struct TokenManager { + secret: Vec, + access_ttl: TimeDelta, +} + +impl TokenManager { + pub fn new(secret: &str, access_ttl: TimeDelta) -> Self { + Self { + secret: secret.as_bytes().to_vec(), + access_ttl, + } + } + + pub fn access_token( + &self, + user_id: &str, + email: &str, + ) -> Result<(String, DateTime), String> { + let now = Utc::now(); + let expires = now + self.access_ttl; + let claims = Claims { + subject: user_id.into(), + email: email.into(), + issuer: "balance-server".into(), + issued: now.timestamp(), + expires: expires.timestamp(), + id: random_token(16)?, + }; + let payload = serde_json::to_vec(&claims).map_err(|error| error.to_string())?; + let unsigned = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#), + URL_SAFE_NO_PAD.encode(payload) + ); + Ok((format!("{}.{}", unsigned, self.sign(&unsigned)), expires)) + } + + pub fn verify(&self, token: &str) -> Result { + let parts: Vec<_> = token.split('.').collect(); + if parts.len() != 3 { + return Err("invalid token".into()); + } + let unsigned = format!("{}.{}", parts[0], parts[1]); + // Go compares the encoded signature, so reject noncanonical encodings too. + let expected = self.sign(&unsigned); + let mut mac = + Hmac::::new_from_slice(&self.secret).expect("HMAC accepts any key size"); + mac.update(parts[2].as_bytes()); + let signature_tag = mac.finalize().into_bytes(); + let mut expected_mac = + Hmac::::new_from_slice(&self.secret).expect("HMAC accepts any key size"); + expected_mac.update(expected.as_bytes()); + if expected_mac.verify_slice(&signature_tag).is_err() { + return Err("invalid token signature".into()); + } + let payload = + decode_go_base64(parts[1], true).map_err(|_| "invalid token payload".to_string())?; + let claims: Claims = serde_json::from_slice(&crate::go_json::normalize(&payload)) + .map_err(|_| "invalid token claims".to_string())?; + if claims.issuer != "balance-server" + || claims.subject.is_empty() + || Utc::now().timestamp() >= claims.expires + { + return Err("expired or invalid token".into()); + } + Ok(claims) + } + + fn sign(&self, value: &str) -> String { + let mut mac = + Hmac::::new_from_slice(&self.secret).expect("HMAC accepts any key size"); + mac.update(value.as_bytes()); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jwt_claims_follow_go_scalar_and_duplicate_field_rules() { + let manager = TokenManager::new("secret", TimeDelta::minutes(1)); + let claims = r#"{"SUB":"old","sub":"user","sub":null,"ISS":"balance-server","EXP":4102444800,"exp":null,"EMAIL":null,"iat":null,"jti":"id","JTI":null}"#; + // The Go verifier does not inspect or decode the JWT header. + let unsigned = format!("ignored-header.{}", URL_SAFE_NO_PAD.encode(claims)); + let token = format!("{}.{}", unsigned, manager.sign(&unsigned)); + let actual = manager.verify(&token).unwrap(); + assert_eq!(actual.subject, "user"); + assert_eq!(actual.email, ""); + assert_eq!(actual.expires, 4_102_444_800); + assert_eq!(actual.id, "id"); + for invalid in [r#"{"sub":1}"#, r#"{"iat":1.0}"#, r#"[]"#] { + assert!(serde_json::from_str::(invalid).is_err()); + } + } + + #[test] + fn jwt_claims_replace_malformed_unicode_as_go_does() { + let manager = TokenManager::new("secret", TimeDelta::minutes(1)); + for payload in [ + br#"{"sub":"\ud800","iss":"balance-server","exp":4102444800}"#.as_slice(), + b"{\"sub\":\"\xff\",\"iss\":\"balance-server\",\"exp\":4102444800}".as_slice(), + ] { + let unsigned = format!("ignored.{}", URL_SAFE_NO_PAD.encode(payload)); + let token = format!("{}.{}", unsigned, manager.sign(&unsigned)); + assert_eq!(manager.verify(&token).unwrap().subject, "�"); + } + } + + #[test] + fn go_base64_ignores_line_breaks_and_unused_bits_only() { + assert_eq!(decode_go_base64("Zh\r\n", true).unwrap(), b"f"); + assert_eq!(decode_go_base64("Zh\r\n", false).unwrap(), b"f"); + for invalid in ["Zg=", "Z g", "Zg\t", "Z"] { + assert!(decode_go_base64(invalid, true).is_err()); + } + assert_eq!( + password_parameters("m=65536,t=3,p=2suffix"), + Some((65536, 3, 2)) + ); + assert_eq!(password_parameters("m=65536,t= 3,p=2"), Some((65536, 3, 2))); + assert_eq!(password_parameters("m=65536,t=3,p=256"), None); + assert_eq!(password_parameters("m=+65536,t=3,p=2"), None); + } + + #[test] + fn accepts_existing_password_hash_with_go_parameters() { + // Fixed salt and an independently computed libargon2 reference result. + // Go's argon2.IDKey uses this same Argon2id v19 representation. + let encoded = "$argon2id$v=19$m=65536,t=3,p=2$MDEyMzQ1Njc4OWFiY2RlZg$0R79n3aEb762TWx/8plp8QrcDJd8M65F5ccBf1ugp4Y"; + assert!(verify_password(encoded, "Go-compatible-✓")); + assert!(!verify_password(encoded, "Go-compatible-x")); + assert_eq!( + hash_password("short").unwrap_err(), + "password must contain at least 8 characters" + ); + assert_eq!( + hash_password(&"x".repeat(1025)).unwrap_err(), + "password is too long" + ); + // The original Go implementation measures UTF-8 bytes, not characters. + assert!(hash_password("éééé").is_ok()); + for malformed in [ + "", + "$argon2i$v=19$m=65536,t=3,p=2$c2FsdA$aGFzaA", + "$argon2id$v=19$m=999999,t=3,p=2$c2FsdA$aGFzaA", + "$argon2id$v=19$m=65536,t=3,p=2$!$!", + ] { + assert!(!verify_password(malformed, "password")); + } + } + + #[test] + fn accepts_go_jwt_wire_format_and_preserves_refresh_hashes() { + // Signed independently using Go's exact header, claims and HS256 encoding. + let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnby11c2VyIiwiZW1haWwiOiJnb0BleGFtcGxlLmNvbSIsImlzcyI6ImJhbGFuY2Utc2VydmVyIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsImp0aSI6ImdvLXNlc3Npb24ifQ.BJrD7QmRGbRhYqS3EI1stCV5AEkocqOcFAJ3yC4MqeI"; + let manager = TokenManager::new("go-fixture-secret", TimeDelta::minutes(15)); + let claims = manager.verify(token).unwrap(); + assert_eq!(claims.subject, "go-user"); + assert_eq!(claims.email, "go@example.com"); + assert_eq!(claims.expires, 4_102_444_800); + assert_eq!(claims.id, "go-session"); + assert!(manager.verify(&format!("{token}=")).is_err()); + assert!( + manager + .verify(&token.replace("eyJzdWI", "eyJzdWJ")) + .is_err() + ); + assert_eq!( + token_hash("existing-go-refresh-token"), + "66e9debb6786892dfecaec6bff6761635629efae55baaeb78645820772911605" + ); + assert_eq!( + URL_SAFE_NO_PAD + .decode(random_token(48).unwrap()) + .unwrap() + .len(), + 48 + ); + let id = identifier().unwrap(); + assert_eq!(id.len(), 32); + assert!( + id.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + ); + } + + #[test] + fn passwords_and_tokens_round_trip() { + let encoded = hash_password("password123").unwrap(); + assert!(encoded.starts_with("$argon2id$v=19$m=65536,t=3,p=2$")); + assert!(verify_password(&encoded, "password123")); + assert!(!verify_password(&encoded, "wrong")); + let manager = TokenManager::new("test secret", TimeDelta::minutes(15)); + let (token, _) = manager.access_token("user", "user@example.com").unwrap(); + assert_eq!(manager.verify(&token).unwrap().subject, "user"); + assert!( + TokenManager::new("other", TimeDelta::minutes(15)) + .verify(&token) + .is_err() + ); + let (expired, _) = TokenManager::new("test secret", TimeDelta::seconds(-1)) + .access_token("user", "email") + .unwrap(); + assert!(manager.verify(&expired).is_err()); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7d99fc7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,160 @@ +use chrono::TimeDelta; +use std::env; + +#[derive(Clone, Debug)] +pub struct Config { + pub address: String, + pub database_path: String, + pub jwt_secret: String, + pub access_ttl: TimeDelta, + pub refresh_ttl: TimeDelta, + pub allow_registration: bool, + pub cors_origins: Vec, +} + +impl Config { + pub fn load() -> Result { + let access_ttl = duration("BALANCE_ACCESS_TTL", TimeDelta::minutes(15))?; + let refresh_ttl = duration("BALANCE_REFRESH_TTL", TimeDelta::days(30))?; + let allow_registration = match value("BALANCE_ALLOW_REGISTRATION").as_str() { + "" | "1" | "t" | "T" | "TRUE" | "true" | "True" => true, + "0" | "f" | "F" | "FALSE" | "false" | "False" => false, + _ => return Err("BALANCE_ALLOW_REGISTRATION must be true or false".into()), + }; + let jwt_secret = value("BALANCE_JWT_SECRET"); + if jwt_secret.len() < 32 { + return Err("BALANCE_JWT_SECRET must contain at least 32 characters".into()); + } + Ok(Self { + address: fallback("BALANCE_ADDR", ":8080"), + database_path: fallback("BALANCE_DB_PATH", "./data/balance.db"), + jwt_secret, + access_ttl, + refresh_ttl, + allow_registration, + cors_origins: value("BALANCE_CORS_ORIGINS") + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(str::to_owned) + .collect(), + }) + } +} + +fn value(key: &str) -> String { + env::var(key).unwrap_or_default().trim().to_owned() +} +fn fallback(key: &str, default: &str) -> String { + let value = value(key); + if value.is_empty() { + default.to_owned() + } else { + value + } +} +fn duration(key: &str, default: TimeDelta) -> Result { + let value = value(key); + if value.is_empty() { + return Ok(default); + } + parse_go_duration(&value) + .ok_or_else(|| format!("{key} must be a Go duration such as 15m or 720h")) +} + +/// Parse Go's signed, nanosecond-precision duration syntax and int64 range. +pub fn parse_go_duration(input: &str) -> Option { + let (negative, mut remaining) = match input.as_bytes().first()? { + b'-' => (true, &input[1..]), + b'+' => (false, &input[1..]), + _ => (false, input), + }; + if remaining == "0" { + return Some(TimeDelta::zero()); + } + if remaining.is_empty() { + return None; + } + let mut total = 0i128; + while !remaining.is_empty() { + let integer_len = remaining.bytes().take_while(u8::is_ascii_digit).count(); + let integer = if integer_len == 0 { + 0 + } else { + remaining[..integer_len].parse::().ok()? + }; + remaining = &remaining[integer_len..]; + let mut fraction = 0i128; + let mut scale = 1i128; + let mut fraction_len = 0; + if remaining.starts_with('.') { + remaining = &remaining[1..]; + fraction_len = remaining.bytes().take_while(u8::is_ascii_digit).count(); + // Beyond 18 decimal places no supported unit can affect nanoseconds. + for digit in remaining.bytes().take(fraction_len.min(18)) { + fraction = fraction * 10 + i128::from(digit - b'0'); + scale *= 10; + } + remaining = &remaining[fraction_len..]; + } + if integer_len == 0 && fraction_len == 0 { + return None; + } + let (unit_text, unit) = [ + ("ns", 1i128), + ("us", 1_000), + ("µs", 1_000), + ("μs", 1_000), + ("ms", 1_000_000), + ("s", 1_000_000_000), + ("m", 60_000_000_000), + ("h", 3_600_000_000_000), + ] + .into_iter() + .find(|(unit, _)| remaining.starts_with(unit))?; + remaining = &remaining[unit_text.len()..]; + total = total + .checked_add(integer.checked_mul(unit)?)? + .checked_add(fraction * unit / scale)?; + if total > i128::from(i64::MAX) + i128::from(negative) { + return None; + } + } + let nanos = i64::try_from(if negative { -total } else { total }).ok()?; + Some(TimeDelta::nanoseconds(nanos)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn duration_matches_go_syntax_and_range() { + assert_eq!(parse_go_duration("1h30m"), Some(TimeDelta::minutes(90))); + assert_eq!( + parse_go_duration("-.5s"), + Some(TimeDelta::milliseconds(-500)) + ); + assert_eq!(parse_go_duration("1µs"), Some(TimeDelta::microseconds(1))); + assert_eq!(parse_go_duration("1.s"), Some(TimeDelta::seconds(1))); + assert_eq!(parse_go_duration("0"), Some(TimeDelta::zero())); + assert_eq!( + parse_go_duration("9223372036854775807ns"), + Some(TimeDelta::nanoseconds(i64::MAX)) + ); + assert_eq!( + parse_go_duration("-9223372036854775808ns"), + Some(TimeDelta::nanoseconds(i64::MIN)) + ); + for invalid in [ + "", + "1d", + "1", + "9223372036854775808ns", + "1h 2m", + ".s", + "--1s", + ] { + assert!(parse_go_duration(invalid).is_none(), "{invalid}"); + } + } +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..9e8d864 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,185 @@ +use rusqlite::Connection; +use std::{error::Error, path::Path, time::Duration}; + +/// Open the existing Go server's SQLite database without changing its schema. +pub fn open(path: &str) -> Result> { + if let Some(parent) = Path::new(path) + .parent() + .filter(|p| !p.as_os_str().is_empty()) + { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + // Match Go's MkdirAll(..., 0750): the database directory must not be + // accessible to other local users under the usual 022 umask. + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o750); + } + builder.create(parent)?; + } + let db = Connection::open(path)?; + db.pragma_update(None, "foreign_keys", true)?; + db.pragma_update(None, "journal_mode", "WAL")?; + db.busy_timeout(Duration::from_secs(5))?; + migrate(&db)?; + Ok(db) +} + +pub fn migrate(db: &Connection) -> rusqlite::Result<()> { + db.execute_batch( + "CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens(user_id); +CREATE TABLE IF NOT EXISTS records ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity TEXT NOT NULL, + record_id TEXT NOT NULL, + payload TEXT, + updated_at TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL, + PRIMARY KEY (user_id, entity, record_id) +); +CREATE TABLE IF NOT EXISTS changes ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity TEXT NOT NULL, + record_id TEXT NOT NULL, + payload TEXT, + updated_at TEXT NOT NULL, + deleted INTEGER NOT NULL, + version INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_is_repeatable_and_enforces_existing_constraints() { + let db = open(":memory:").unwrap(); + migrate(&db).unwrap(); + db.execute( + "INSERT INTO users VALUES ('u','Test@example.com','hash','now')", + [], + ) + .unwrap(); + assert!( + db.execute( + "INSERT INTO users VALUES ('v','test@example.com','hash','now')", + [] + ) + .is_err() + ); + assert!( + db.execute( + "INSERT INTO records VALUES ('missing','category','c',NULL,'now',1,1)", + [] + ) + .is_err() + ); + db.execute("INSERT INTO refresh_tokens(token_hash,user_id,expires_at,created_at) VALUES ('hash','u','then','now')", []).unwrap(); + db.execute("DELETE FROM users WHERE id='u'", []).unwrap(); + assert_eq!( + db.query_row("SELECT count(*) FROM refresh_tokens", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + } + + #[test] + fn opens_populated_go_schema_without_changing_rows_or_sequence() { + let directory = std::env::temp_dir().join(format!( + "balance-go-db-{}", + crate::auth::identifier().unwrap() + )); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("existing.sqlite"); + { + let db = Connection::open(&path).unwrap(); + // Use the original schema, so a divergence between implementations + // is caught even when Rust's migration is internally consistent. + let go_source = include_str!("../internal/database/database.go"); + let schema = go_source + .split("const schema = `") + .nth(1) + .unwrap() + .split('`') + .next() + .unwrap(); + db.execute_batch(schema).unwrap(); + db.execute_batch( + "INSERT INTO users VALUES ('go-user','go@example.com','existing hash','2026-01-01T00:00:00.123456789Z'); + INSERT INTO refresh_tokens(id,token_hash,user_id,expires_at,created_at) VALUES (7,'existing-token-hash','go-user','2099-01-01T00:00:00Z','2026-01-01T00:00:00Z'); + INSERT INTO records VALUES ('go-user','transaction','record', '{\"amount\":12.5}', '2026-01-01T00:00:00.123456789Z',0,41); + INSERT INTO changes VALUES (41,'go-user','transaction','record','{\"amount\":12.5}','2026-01-01T00:00:00.123456789Z',0,41);" + ).unwrap(); + } + { + let db = open(path.to_str().unwrap()).unwrap(); + let user: (String, String) = db + .query_row( + "SELECT password_hash,created_at FROM users WHERE id='go-user'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!( + user, + ( + "existing hash".into(), + "2026-01-01T00:00:00.123456789Z".into() + ) + ); + assert_eq!( + db.query_row( + "SELECT id FROM refresh_tokens WHERE revoked_at IS NULL", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 7 + ); + assert_eq!( + db.query_row("SELECT payload FROM records", [], |r| r.get::<_, String>(0)) + .unwrap(), + "{\"amount\":12.5}" + ); + db.execute("INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES ('go-user','transaction','next',NULL,'now',1,42)", []).unwrap(); + assert_eq!(db.last_insert_rowid(), 42); + assert_eq!( + db.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + db.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0)) + .unwrap(), + "wal" + ); + assert_eq!( + db.query_row("PRAGMA busy_timeout", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 5000 + ); + } + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src/go_json.rs b/src/go_json.rs new file mode 100644 index 0000000..3f0d354 --- /dev/null +++ b/src/go_json.rs @@ -0,0 +1,326 @@ +//! Compatibility with encoding/json's replacement of malformed Unicode in +//! decoded JSON strings. Syntax errors remain errors for the JSON decoder. +//! +//! Apply this only after enforcing limits on the original request bytes. +//! RawMessage/RawValue fields must retain their original input separately: +//! Go does not replace malformed Unicode inside a RawMessage. + +/// Replace malformed UTF-8 and unpaired UTF-16 escapes inside JSON strings. +/// Valid bytes, whitespace and escape spellings are preserved exactly. +pub fn normalize(input: &[u8]) -> Vec { + let mut output = Vec::with_capacity(input.len()); + let mut index = 0; + let mut in_string = false; + while index < input.len() { + let byte = input[index]; + if !in_string { + output.push(byte); + in_string = byte == b'"'; + index += 1; + continue; + } + match byte { + b'"' => { + output.push(byte); + in_string = false; + index += 1; + } + b'\\' => { + if let Some(code) = unicode_escape(&input[index..]) { + if (0xD800..=0xDBFF).contains(&code) { + if unicode_escape(&input[index + 6..]) + .is_some_and(|low| (0xDC00..=0xDFFF).contains(&low)) + { + output.extend_from_slice(&input[index..index + 12]); + index += 12; + continue; + } + output.extend_from_slice(b"\\uFFFD"); + } else if (0xDC00..=0xDFFF).contains(&code) { + output.extend_from_slice(b"\\uFFFD"); + } else { + output.extend_from_slice(&input[index..index + 6]); + } + index += 6; + } else { + // Skip an escaped quote/backslash as one unit; invalid + // escape syntax is left intact for serde to reject. + let end = (index + 2).min(input.len()); + output.extend_from_slice(&input[index..end]); + index = end; + } + } + 0..=0x7f => { + output.push(byte); + index += 1; + } + _ => { + let length = match byte { + 0xC2..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF4 => 4, + _ => 0, + }; + if length != 0 + && index + length <= input.len() + && std::str::from_utf8(&input[index..index + length]).is_ok() + { + output.extend_from_slice(&input[index..index + length]); + index += length; + } else { + // Go's utf8.DecodeRune consumes one byte for every error, + // including each byte of an incomplete sequence. + output.extend_from_slice("\u{FFFD}".as_bytes()); + index += 1; + } + } + } + } + output +} + +fn unicode_escape(input: &[u8]) -> Option { + if input.len() < 6 || &input[..2] != b"\\u" { + return None; + } + input[2..6].iter().try_fold(0u16, |value, byte| { + let digit = match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + b'A'..=b'F' => byte - b'A' + 10, + _ => return None, + }; + Some(value * 16 + u16::from(digit)) + }) +} + +/// Replace named raw fields with numbered JSON strings before scalar decoding. +/// The returned raw values retain the original byte spelling and whitespace. +pub fn extract_raw_fields(input: &[u8], field: &str) -> Result<(Vec, Vec>), ()> { + let start = skip_space(input, 0); + let end = value_end(input, start)?; + let mut output = Vec::with_capacity(input.len()); + let mut raw = Vec::new(); + let mut copied = 0; + let mut index = start; + while index < end { + if input[index] != b'"' { + index += 1; + continue; + } + let string_end = quoted_end(input, index)?; + let colon = skip_space(input, string_end); + if input.get(colon) == Some(&b':') { + let key: String = + serde_json::from_slice(&normalize(&input[index..string_end])).map_err(|_| ())?; + if key.to_ascii_lowercase().replace('ſ', "s") == field { + let value_start = skip_space(input, colon + 1); + let value_end = value_end(input, value_start)?; + let bytes = &input[value_start..value_end]; + // Validate raw syntax without converting numbers or strings. + serde_json::from_slice::>(&normalize(bytes)) + .map_err(|_| ())?; + output.extend_from_slice(&input[copied..value_start]); + output.extend_from_slice(format!("\"{}\"", raw.len()).as_bytes()); + raw.push(bytes.to_vec()); + copied = value_end; + index = value_end; + continue; + } + } + index = string_end; + } + output.extend_from_slice(&input[copied..]); + Ok((output, raw)) +} + +fn skip_space(input: &[u8], mut index: usize) -> usize { + while input + .get(index) + .is_some_and(|b| matches!(b, b' ' | b'\r' | b'\n' | b'\t')) + { + index += 1; + } + index +} + +fn quoted_end(input: &[u8], mut index: usize) -> Result { + index += 1; + while index < input.len() { + match input[index] { + b'"' => return Ok(index + 1), + b'\\' => index += 2, + _ => index += 1, + } + } + Err(()) +} + +fn value_end(input: &[u8], start: usize) -> Result { + match input.get(start).ok_or(())? { + b'"' => quoted_end(input, start), + b'[' | b'{' => { + let mut depth = 1usize; + let mut index = start + 1; + while index < input.len() { + match input[index] { + b'"' => { + index = quoted_end(input, index)?; + continue; + } + b'[' | b'{' => { + depth += 1; + if depth > 10_000 { + return Err(()); + } + } + b']' | b'}' => { + depth -= 1; + if depth == 0 { + return Ok(index + 1); + } + } + _ => {} + } + index += 1; + } + Err(()) + } + _ => { + let length = input[start..] + .iter() + .position(|b| matches!(b, b' ' | b'\r' | b'\n' | b'\t' | b',' | b']' | b'}')) + .unwrap_or(input.len() - start); + if length == 0 { + Err(()) + } else { + Ok(start + length) + } + } + } +} + +/// Go's encoding/json compacts RawMessage and escapes HTML-sensitive bytes, +/// preserving duplicate keys, number spellings and malformed Unicode bytes. +pub fn compact_raw(input: &[u8]) -> Vec { + let mut result = Vec::with_capacity(input.len()); + let mut in_string = false; + let mut escaped = false; + let mut index = 0; + while index < input.len() { + let byte = input[index]; + if !in_string && matches!(byte, b' ' | b'\n' | b'\r' | b'\t') { + index += 1; + continue; + } + if in_string && !escaped { + let replacement: Option<&[u8]> = match byte { + b'<' => Some(b"\\u003c"), + b'>' => Some(b"\\u003e"), + b'&' => Some(b"\\u0026"), + _ => None, + }; + if let Some(value) = replacement { + result.extend_from_slice(value); + index += 1; + continue; + } + if input[index..].starts_with(&[0xE2, 0x80, 0xA8]) + || input[index..].starts_with(&[0xE2, 0x80, 0xA9]) + { + result.extend_from_slice(if input[index + 2] == 0xA8 { + b"\\u2028" + } else { + b"\\u2029" + }); + index += 3; + continue; + } + } + result.push(byte); + if escaped { + escaped = false; + } else if in_string && byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = !in_string; + } + index += 1; + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_valid_json_and_literal_backslashes() { + let input = + br#" {"escaped":"\\ud800", "pair":"\ud83d\ude00", "quote":"\"", "slash":"\\"} "#; + assert_eq!(normalize(input), input); + serde_json::from_slice::(&normalize(input)).unwrap(); + let unicode = "{\"text\":\"Привет 🌍\"}".as_bytes(); + assert_eq!(normalize(unicode), unicode); + } + + #[test] + fn replaces_unpaired_surrogates_without_consuming_following_escapes() { + let input = br#"["\ud800", "\udc00", "\ud800\u0061", "\ud800\ud800\udc00"]"#; + let value: Vec = serde_json::from_slice(&normalize(input)).unwrap(); + assert_eq!(value, ["�", "�", "�a", "�𐀀"]); + } + + #[test] + fn replaces_each_malformed_utf8_byte_as_go_does() { + for (input, expected) in [ + (b"\"\xff\"".as_slice(), "�"), + (b"\"\xe1\x80\"".as_slice(), "��"), + (b"\"\xed\xa0\x80\"".as_slice(), "���"), + (b"\"\xf4\x90\x80\x80\"".as_slice(), "����"), + ] { + let decoded: String = serde_json::from_slice(&normalize(input)).unwrap(); + assert_eq!(decoded, expected); + } + } + + #[test] + fn leaves_invalid_json_syntax_rejected() { + for input in [ + br#""\uZZZZ""#.as_slice(), + br#""\x""#.as_slice(), + b"\"raw\nnewline\"".as_slice(), + b"{\xff:1}".as_slice(), + ] { + assert!(serde_json::from_slice::(&normalize(input)).is_err()); + } + } + + #[test] + fn raw_extraction_preserves_bytes_and_ignores_trailing_json() { + let input = b"{\"payload\": { \"x\":\"\\ud800\", \"x\":\"\xff\" },\"PAYLOAD\":null} {\"payload\":not-json}"; + let (masked, values) = extract_raw_fields(input, "payload").unwrap(); + assert_eq!( + values, + [ + b"{ \"x\":\"\\ud800\", \"x\":\"\xff\" }".to_vec(), + b"null".to_vec() + ] + ); + assert_eq!( + masked, + b"{\"payload\": \"0\",\"PAYLOAD\":\"1\"} {\"payload\":not-json}" + ); + assert!(extract_raw_fields(br#"{"payload":{"bad":}}"#, "payload").is_err()); + assert!(extract_raw_fields(br#"{"payload":[1,]}"#, "payload").is_err()); + } + + #[test] + fn raw_compaction_matches_go_html_escaping_without_decoding_strings() { + let input = "{ \"raw\":\"<&>\u{2028}\u{2029}\", \"escaped\":\"\\u003c\", \"dup\":1, \"dup\":2, \"n\":1.00e+999 }"; + assert_eq!( + compact_raw(input.as_bytes()), + br#"{"raw":"\u003c\u0026\u003e\u2028\u2029","escaped":"\u003c","dup":1,"dup":2,"n":1.00e+999}"# + ); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..666eb3f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,49 @@ +mod auth; +mod config; +mod database; +mod go_json; +mod runtime; +mod server; +mod sync; + +use std::sync::{Arc, Mutex}; +fn main() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("create async runtime"); + let result = runtime.block_on(run()); + // Connection draining already has a ten-second budget. Blocking database + // or password jobs must not extend process shutdown beyond that budget. + runtime.shutdown_timeout(std::time::Duration::ZERO); + if let Err(e) = result { + eprintln!("Balance server startup failed: {e}"); + std::process::exit(1); + } +} +async fn run() -> Result<(), Box> { + let cfg = config::Config::load()?; + let db = database::open(&cfg.database_path)?; + let address = cfg.address.clone(); + let listener = runtime::bind(&address).await?; + let state = Arc::new(server::State::new(cfg, Mutex::new(db))); + let app = axum::Router::new() + .fallback(server::handle) + .with_state(state); + eprintln!("Balance server listening on {address}"); + runtime::serve(listener, app, runtime::Timeouts::default(), async { + #[cfg(unix)] + { + let mut term = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("signal handler"); + tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = term.recv() => {} } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } + }) + .await?; + Ok(()) +} diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..bc295ed --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,550 @@ +//! HTTP/1 transport deadlines matching net/http.Server's socket deadlines. +use axum::{Router, body::Body, extract::ConnectInfo}; +use http_body::{Body as HttpBody, Frame, SizeHint}; +use hyper::{server::conn::http1, service::service_fn}; +use hyper_util::rt::TokioIo; +use std::{ + future::Future, + io, + net::SocketAddr, + pin::Pin, + sync::{Arc, Mutex}, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + net::{TcpListener, TcpStream}, + sync::watch, + task::JoinSet, + time::{Instant, Sleep}, +}; +use tower::ServiceExt; + +#[derive(Clone, Copy)] +pub struct Timeouts { + pub header: Duration, + pub read: Duration, + pub write: Duration, + pub idle: Duration, + pub shutdown: Duration, +} +impl Default for Timeouts { + fn default() -> Self { + Self { + header: Duration::from_secs(5), + read: Duration::from_secs(15), + write: Duration::from_secs(30), + idle: Duration::from_secs(60), + shutdown: Duration::from_secs(10), + } + } +} + +struct Deadlines { + started: Instant, + read: Option, + write: Option, + idle: bool, + response_done: bool, +} +struct DeadlineIo { + stream: TcpStream, + shared: Arc>, + timeouts: Timeouts, + read_timer: Pin>, + write_timer: Pin>, +} +fn expired(timer: &mut Pin>, deadline: Option, cx: &mut Context<'_>) -> bool { + if let Some(deadline) = deadline { + timer.as_mut().reset(deadline); + timer.as_mut().poll(cx).is_ready() + } else { + false + } +} +fn timed_out() -> io::Error { + io::Error::new(io::ErrorKind::TimedOut, "HTTP connection deadline exceeded") +} +impl AsyncRead for DeadlineIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let deadline = self.shared.lock().unwrap().read; + if expired(&mut self.read_timer, deadline, cx) { + return Poll::Ready(Err(timed_out())); + } + let before = buf.filled().len(); + let result = Pin::new(&mut self.stream).poll_read(cx, buf); + if matches!(result, Poll::Ready(Ok(()))) && buf.filled().len() > before { + let mut state = self.shared.lock().unwrap(); + if state.idle { + state.idle = false; + state.started = Instant::now(); + state.read = Some(state.started + self.timeouts.header); + cx.waker().wake_by_ref(); + } + } + result + } +} +impl AsyncWrite for DeadlineIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let deadline = self.shared.lock().unwrap().write; + if expired(&mut self.write_timer, deadline, cx) { + return Poll::Ready(Err(timed_out())); + } + Pin::new(&mut self.stream).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let deadline = self.shared.lock().unwrap().write; + if expired(&mut self.write_timer, deadline, cx) { + return Poll::Ready(Err(timed_out())); + } + let result = Pin::new(&mut self.stream).poll_flush(cx); + if matches!(result, Poll::Ready(Ok(()))) { + let mut state = self.shared.lock().unwrap(); + if state.response_done { + state.write = None; + state.read = Some(Instant::now() + self.timeouts.idle); + state.idle = true; + state.response_done = false; + cx.waker().wake_by_ref(); + } + } + result + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(cx) + } +} +struct ResponseBody { + inner: Body, + shared: Arc>, +} +impl HttpBody for ResponseBody { + type Data = axum::body::Bytes; + type Error = axum::Error; + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let frame = Pin::new(&mut self.inner).poll_frame(cx); + if frame.is_ready() && self.inner.is_end_stream() { + self.shared.lock().unwrap().response_done = true; + } + frame + } + fn is_end_stream(&self) -> bool { + self.inner.is_end_stream() + } + fn size_hint(&self) -> SizeHint { + self.inner.size_hint() + } +} + +async fn connection( + stream: TcpStream, + peer: SocketAddr, + app: Router, + timeouts: Timeouts, + mut shutdown: watch::Receiver, +) { + let now = Instant::now(); + let state = Arc::new(Mutex::new(Deadlines { + started: now, + read: Some(now + timeouts.header), + write: None, + idle: false, + response_done: false, + })); + let io = DeadlineIo { + stream, + shared: Arc::clone(&state), + timeouts, + read_timer: Box::pin(tokio::time::sleep(timeouts.header)), + write_timer: Box::pin(tokio::time::sleep(timeouts.write)), + }; + let service = service_fn(move |mut request: hyper::Request| { + request.extensions_mut().insert(ConnectInfo(peer)); + let app = app.clone(); + let state = Arc::clone(&state); + { + let mut deadlines = state.lock().unwrap(); + if deadlines.idle { + deadlines.started = Instant::now(); + } + deadlines.idle = false; + deadlines.response_done = false; + deadlines.read = Some(deadlines.started + timeouts.read); + deadlines.write = Some(Instant::now() + timeouts.write); + } + async move { + let response = app.oneshot(request.map(Body::new)).await?; + let (parts, body) = response.into_parts(); + if body.is_end_stream() { + state.lock().unwrap().response_done = true; + } + Ok::<_, std::convert::Infallible>(hyper::Response::from_parts( + parts, + ResponseBody { + inner: body, + shared: state, + }, + )) + } + }); + let conn = http1::Builder::new().serve_connection(TokioIo::new(io), service); + tokio::pin!(conn); + tokio::select! { + _ = &mut conn => {}, + _ = shutdown.changed() => { conn.as_mut().graceful_shutdown(); let _ = conn.await; } + } +} + +/// Go's empty host listens on both IP families where IPv6 is available. +pub async fn bind(address: &str) -> io::Result { + if address.starts_with(':') { + match TcpListener::bind(format!("[::]{address}")).await { + Ok(listener) => Ok(listener), + Err(_) => TcpListener::bind(format!("0.0.0.0{address}")).await, + } + } else { + TcpListener::bind(address).await + } +} + +pub async fn serve( + listener: TcpListener, + app: Router, + timeouts: Timeouts, + shutdown: impl Future, +) -> io::Result<()> { + let (stop, _) = watch::channel(false); + let mut connections = JoinSet::new(); + tokio::pin!(shutdown); + loop { + tokio::select! { + biased; + _ = &mut shutdown => break, + finished = connections.join_next(), if !connections.is_empty() => { let _ = finished; }, + accepted = listener.accept() => { + let (stream, peer) = accepted?; + stream.set_nodelay(true)?; + connections.spawn(connection(stream, peer, app.clone(), timeouts, stop.subscribe())); + } + } + } + drop(listener); + let _ = stop.send(true); + if tokio::time::timeout(timeouts.shutdown, async { + while connections.join_next().await.is_some() {} + }) + .await + .is_err() + { + connections.abort_all(); + while connections.join_next().await.is_some() {} + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::routing::{get, post}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::oneshot, + }; + + fn limits() -> Timeouts { + Timeouts { + header: Duration::from_millis(90), + read: Duration::from_millis(220), + write: Duration::from_millis(300), + idle: Duration::from_millis(500), + shutdown: Duration::from_millis(120), + } + } + async fn start( + app: Router, + ) -> ( + SocketAddr, + oneshot::Sender<()>, + tokio::task::JoinHandle>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (stop, stopped) = oneshot::channel(); + let task = tokio::spawn(serve(listener, app, limits(), async { + let _ = stopped.await; + })); + (addr, stop, task) + } + async fn closed(stream: &mut TcpStream) { + let mut data = [0; 4096]; + tokio::time::timeout(Duration::from_secs(2), async { + match stream.read(&mut data).await { + Ok(0) | Err(_) => {} + Ok(count) => panic!( + "unexpected response before connection closed: {:?}", + &data[..count] + ), + } + }) + .await + .expect("connection deadline was not enforced"); + } + async fn response(stream: &mut TcpStream) -> String { + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + bytes.push( + tokio::time::timeout(Duration::from_secs(2), stream.read_u8()) + .await + .unwrap() + .unwrap(), + ); + } + let header = String::from_utf8(bytes).unwrap(); + let len: usize = header + .lines() + .find_map(|l| { + l.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|s| s.trim().parse().unwrap()) + }) + .unwrap_or(0); + let mut body = vec![0; len]; + stream.read_exact(&mut body).await.unwrap(); + assert_eq!(body, b"ok"); + header + } + + #[tokio::test] + async fn incomplete_headers_expire_from_connection_start() { + let (addr, stop, task) = start(Router::new().route("/", get(|| async { "ok" }))).await; + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream.write_all(b"GET / HTTP/1.1\r\nHost: ").await.unwrap(); + let started = Instant::now(); + closed(&mut stream).await; + assert!(started.elapsed() < Duration::from_millis(500)); + stop.send(()).unwrap(); + task.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn body_deadline_includes_header_time_and_is_not_reset_by_progress() { + let app = Router::new().route( + "/", + post(|body: axum::body::Bytes| async move { + assert!(body.len() < 10); + "ok" + }), + ); + let (addr, stop, task) = start(app).await; + let mut stream = TcpStream::connect(addr).await.unwrap(); + let started = Instant::now(); + stream + .write_all(b"POST / HTTP/1.1\r\nHost: localhost\r\n") + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + stream + .write_all(b"Content-Length: 10\r\n\r\na") + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + stream.write_all(b"b").await.unwrap(); + // Axum reports a body-read error, which may generate an HTTP error + // response; drain it and wait for the read deadline to close the socket. + let mut bytes = Vec::new(); + tokio::time::timeout(Duration::from_secs(1), stream.read_to_end(&mut bytes)) + .await + .unwrap() + .unwrap(); + assert!(started.elapsed() >= Duration::from_millis(200)); + assert!(started.elapsed() < Duration::from_millis(400)); + stop.send(()).unwrap(); + task.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn idle_deadline_is_longer_than_header_and_next_headers_get_own_deadline() { + let (addr, stop, task) = start(Router::new().route("/", get(|| async { "ok" }))).await; + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + assert!(response(&mut stream).await.starts_with("HTTP/1.1 200")); + tokio::time::sleep(Duration::from_millis(180)).await; + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + assert!(response(&mut stream).await.starts_with("HTTP/1.1 200")); + tokio::time::sleep(Duration::from_millis(150)).await; + stream.write_all(b"GET / HTTP/1.1\r\nHost: ").await.unwrap(); + let started = Instant::now(); + closed(&mut stream).await; + assert!(started.elapsed() < Duration::from_millis(250)); + stop.send(()).unwrap(); + task.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn idle_keepalive_eventually_closes_without_another_request() { + let (addr, stop, task) = start(Router::new().route("/", get(|| async { "ok" }))).await; + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + response(&mut stream).await; + let started = Instant::now(); + closed(&mut stream).await; + assert!(started.elapsed() >= Duration::from_millis(450)); + assert!(started.elapsed() < Duration::from_millis(800)); + stop.send(()).unwrap(); + task.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn responses_cannot_be_written_after_write_deadline() { + let app = Router::new().route( + "/", + get(|| async { + tokio::time::sleep(Duration::from_millis(350)).await; + "ok" + }), + ); + let (addr, stop, task) = start(app).await; + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + closed(&mut stream).await; + stop.send(()).unwrap(); + task.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn shutdown_drains_active_requests_and_bounds_stuck_handlers() { + let (entered, notified) = oneshot::channel(); + let entered = Arc::new(Mutex::new(Some(entered))); + let app = Router::new().route( + "/", + get(move || { + let entered = Arc::clone(&entered); + async move { + if let Some(entered) = entered.lock().unwrap().take() { + let _ = entered.send(()); + } + std::future::pending::<()>().await; + "ok" + } + }), + ); + let (addr, stop, task) = start(app).await; + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + notified.await.unwrap(); + let started = Instant::now(); + stop.send(()).unwrap(); + task.await.unwrap().unwrap(); + assert!(started.elapsed() >= Duration::from_millis(100)); + assert!(started.elapsed() < Duration::from_millis(400)); + assert!(TcpStream::connect(addr).await.is_err()); + closed(&mut stream).await; + } + + #[tokio::test] + async fn shutdown_allows_in_flight_response_to_complete() { + let (entered, notified) = oneshot::channel(); + let entered = Arc::new(Mutex::new(Some(entered))); + let app = Router::new().route( + "/", + get(move || { + let entered = Arc::clone(&entered); + async move { + if let Some(entered) = entered.lock().unwrap().take() { + let _ = entered.send(()); + } + tokio::time::sleep(Duration::from_millis(30)).await; + "ok" + } + }), + ); + let (addr, stop, task) = start(app).await; + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + notified.await.unwrap(); + stop.send(()).unwrap(); + assert!(response(&mut stream).await.starts_with("HTTP/1.1 200")); + task.await.unwrap().unwrap(); + closed(&mut stream).await; + } + #[tokio::test] + async fn address_binding_supports_hostnames_ip_literals_and_empty_host() { + for address in ["127.0.0.1:0", "localhost:0", ":0"] { + let listener = bind(address).await.unwrap(); + let local = listener.local_addr().unwrap(); + assert_ne!(local.port(), 0); + if address == ":0" { + let client = TcpStream::connect(("127.0.0.1", local.port())) + .await + .unwrap(); + let _ = listener.accept().await.unwrap(); + drop(client); + } + } + // The IPv6 syntax remains accepted on hosts with an IPv6 loopback. + match bind("[::1]:0").await { + Ok(listener) => assert!(listener.local_addr().unwrap().is_ipv6()), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported + ) => {} + Err(error) => panic!("IPv6 listener: {error}"), + } + } + #[tokio::test] + async fn blocked_socket_writes_expire_without_waiting_for_client_reads() { + const LENGTH: usize = 16 * 1024 * 1024; + let app = Router::new().route("/", get(|| async { "x".repeat(LENGTH) })); + let (addr, stop, task) = start(app).await; + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + // A response larger than the socket buffer must eventually block. + // The write deadline must wake that pending write and close the + // connection even though the client has not consumed any response. + tokio::time::sleep(Duration::from_millis(400)).await; + let mut received = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut received)) + .await + .unwrap() + .unwrap(); + assert!(received.starts_with(b"HTTP/1.1 200")); + assert!(received.len() < LENGTH); + stop.send(()).unwrap(); + task.await.unwrap().unwrap(); + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..1dde41b --- /dev/null +++ b/src/server.rs @@ -0,0 +1,1015 @@ +use crate::{auth, config::Config, sync}; +use axum::{ + body::{Body, HttpBody}, + extract::{ConnectInfo, State as ExtractState}, + http::{HeaderMap, HeaderValue, Method, Request, StatusCode}, + response::Response, +}; +use chrono::{DateTime, SecondsFormat, Utc}; +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{ + Deserializer, + de::{self, MapAccess, Visitor}, +}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + fmt, + net::SocketAddr, + sync::{Arc, Mutex}, + time::{Duration, Instant, SystemTime}, +}; + +pub struct State { + cfg: Config, + db: Mutex, + tokens: auth::TokenManager, + clients: Mutex>, +} +impl State { + pub fn new(cfg: Config, db: Mutex) -> Self { + Self { + tokens: auth::TokenManager::new(&cfg.jwt_secret, cfg.access_ttl), + cfg, + db, + clients: Mutex::new(HashMap::new()), + } + } +} + +pub async fn handle( + ExtractState(state): ExtractState>, + request: Request, +) -> Response { + let (parts, body) = request.into_parts(); + let headers = parts.headers; + let path = parts.uri.path().to_owned(); + let method = parts.method; + let query = parts.uri.query().map(str::to_owned); + let canonical = clean_path(&path, true); + let host = parts + .extensions + .get::>() + .map(|v| v.0.ip().to_string()) + .unwrap_or_default(); + let is_head = method == Method::HEAD; + let mut response = if method == Method::OPTIONS { + empty(204) + } else if method != Method::CONNECT && canonical != path { + let location = match &query { + Some(q) => format!("{canonical}?{q}"), + None => canonical, + }; + redirect(&location, &method) + } else { + // Bound memory independently of Content-Length, including chunked requests. + let body = read_body(body).await; + let state = state.clone(); + let headers = headers.clone(); + let escaped_slash = path.to_ascii_lowercase().contains("%2f"); + let path = percent_decode(&path); + match tokio::task::spawn_blocking(move || { + // ServeMux unescapes individual segments; an escaped slash cannot + // turn one segment into the separators of a protected API route. + if escaped_slash { + if method == Method::GET || method == Method::HEAD { + return web(&path, &headers); + } + let mut r = plain(405, "Method Not Allowed\n"); + r.headers_mut() + .insert("Allow", HeaderValue::from_static("GET, HEAD")); + return r; + } + dispatch(&state, &method, &path, &headers, body.as_deref(), &host) + }) + .await + { + Ok(response) => response, + Err(_) => error(500, "internal_error", "Internal server error"), + } + }; + let local_location = match ( + &query, + response + .headers() + .get("Location") + .and_then(|v| v.to_str().ok()), + ) { + (Some(q), Some(location)) + if (response.status() == StatusCode::MOVED_PERMANENTLY + || response.status() == StatusCode::TEMPORARY_REDIRECT) + && !location.contains('?') => + { + Some(format!("{location}?{q}")) + } + _ => None, + }; + if let Some(value) = local_location.and_then(|v| HeaderValue::from_str(&v).ok()) { + response.headers_mut().insert("Location", value); + } + let h = response.headers_mut(); + h.insert( + "X-Content-Type-Options", + HeaderValue::from_static("nosniff"), + ); + h.insert("X-Frame-Options", HeaderValue::from_static("DENY")); + h.insert("Referrer-Policy", HeaderValue::from_static("no-referrer")); + h.insert( + "Permissions-Policy", + HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"), + ); + h.insert("Content-Security-Policy", HeaderValue::from_static("default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; manifest-src 'self'; script-src 'self'; style-src 'self'; worker-src 'self'")); + if percent_decode(&path).starts_with("/v1/") { + h.insert("Cache-Control", HeaderValue::from_static("no-store")); + } + let origin = header(&headers, "origin"); + if !origin.is_empty() + && state + .cfg + .cors_origins + .iter() + .any(|v| v == origin || v == "*") + { + if let Ok(value) = HeaderValue::from_str(origin) { + h.insert("Access-Control-Allow-Origin", value); + } + h.insert("Vary", HeaderValue::from_static("Origin")); + h.insert( + "Access-Control-Allow-Headers", + HeaderValue::from_static("Authorization, Content-Type"), + ); + h.insert( + "Access-Control-Allow-Methods", + HeaderValue::from_static("GET, POST, OPTIONS"), + ); + } + if is_head { + *response.body_mut() = Body::empty(); + } + response +} + +// Go's decoder consumes one value, so a valid first value may be followed by +// ignored data beyond MaxBytesReader's cap. Keep the bounded prefix available. +async fn read_body(mut body: Body) -> Result, ()> { + let mut bytes = Vec::new(); + while bytes.len() < 2 << 20 { + let frame = std::future::poll_fn(|cx| std::pin::Pin::new(&mut body).poll_frame(cx)).await; + let Some(frame) = frame else { + break; + }; + let frame = frame.map_err(|_| ())?; + if let Ok(data) = frame.into_data() { + let remaining = (2 << 20) - bytes.len(); + bytes.extend_from_slice(&data[..data.len().min(remaining)]); + } + } + Ok(bytes) +} + +fn dispatch( + state: &State, + method: &Method, + path: &str, + headers: &HeaderMap, + body: Result<&[u8], &()>, + host: &str, +) -> Response { + let post_route = matches!( + path, + "/v1/auth/register" + | "/v1/auth/login" + | "/v1/auth/refresh" + | "/v1/auth/logout" + | "/v1/sync" + ); + if method != Method::GET && method != Method::HEAD && !(method == Method::POST && post_route) { + let mut r = plain(405, "Method Not Allowed\n"); + r.headers_mut().insert( + "Allow", + HeaderValue::from_static(if post_route { + "GET, HEAD, POST" + } else { + "GET, HEAD" + }), + ); + return r; + } + if method != Method::POST { + return match path { + "/health" => json_response(200, json!({"status":"ok", "time":timestamp(Utc::now())})), + "/v1/me" => match authorize(state, headers) { + Ok(c) => json_response(200, json!({"id":c.subject,"email":c.email})), + Err(r) => *r, + }, + _ => web(path, headers), + }; + } + if matches!( + path, + "/v1/auth/register" | "/v1/auth/login" | "/v1/auth/refresh" + ) { + let now = Instant::now(); + let mut clients = state.clients.lock().unwrap_or_else(|e| e.into_inner()); + let bucket = clients.entry(host.to_owned()).or_insert((now, 0)); + if now.duration_since(bucket.0) >= Duration::from_secs(60) { + *bucket = (now, 0); + } + bucket.1 = bucket.1.saturating_add(1); + if bucket.1 > 30 { + let mut r = error(429, "rate_limited", "Too many authentication requests"); + r.headers_mut() + .insert("Retry-After", HeaderValue::from_static("60")); + return r; + } + } + let claims = if path == "/v1/sync" { + match authorize(state, headers) { + Ok(c) => Some(c), + Err(r) => return *r, + } + } else { + None + }; + if path == "/v1/auth/register" && !state.cfg.allow_registration { + return error(403, "registration_disabled", "Registration is disabled"); + } + let Ok(body) = body else { + return invalid_json(); + }; + if let Some(claims) = claims { + return match sync::synchronize_wire( + &mut state.db.lock().unwrap_or_else(|e| e.into_inner()), + &claims.subject, + body, + ) { + Ok(value) => raw_json_response(200, value), + Err((status, code, message)) => error(status, code, &message), + }; + } + let request = match decode( + body, + path == "/v1/auth/register" || path == "/v1/auth/login", + ) { + Ok(r) => r, + Err(_) => return invalid_json(), + }; + match path { + "/v1/auth/register" => register(state, headers, request), + "/v1/auth/login" => login(state, headers, request), + "/v1/auth/refresh" => refresh(state, headers, request), + "/v1/auth/logout" => { + let token = refresh_token(headers, &request.refresh_token); + if !token.is_empty() { + let _ = state.db.lock().unwrap_or_else(|e| e.into_inner()).execute("UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=? AND revoked_at IS NULL", params![timestamp(Utc::now()), auth::token_hash(&token)]); + } + let mut r = empty(204); + for name in ["balance_access", "balance_refresh"] { + append_cookie( + &mut r, + name, + "", + DateTime::from_timestamp(1, 0).unwrap(), + -1, + headers, + ); + } + r + } + _ => error(404, "not_found", "Endpoint not found"), + } +} + +#[derive(Default)] +struct Input { + email: String, + password: String, + refresh_token: String, +} +fn decode(body: &[u8], credentials: bool) -> Result { + struct InputVisitor(bool); + impl<'de> Visitor<'de> for InputVisitor { + type Value = Input; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("an object") + } + fn visit_unit(self) -> Result { + Ok(Input::default()) + } + fn visit_map>(self, mut m: M) -> Result { + let mut r = Input::default(); + while let Some(key) = m.next_key::()? { + let destination = match key + .chars() + .map(|c| match c { + 'ſ' => 's', + 'K' => 'k', + _ => c.to_ascii_lowercase(), + }) + .collect::() + .as_str() + { + "email" if self.0 => &mut r.email, + "password" if self.0 => &mut r.password, + "refreshtoken" if !self.0 => &mut r.refresh_token, + _ => return Err(de::Error::custom("unknown field")), + }; + if let Some(v) = m.next_value::>()? { + *destination = v; + } + } + Ok(r) + } + } + serde_json::Deserializer::from_slice(&crate::go_json::normalize(body)) + .deserialize_any(InputVisitor(credentials)) +} +fn normalize_email(email: &str) -> Option { + let email: String = email + .trim() + .chars() + .map(|c| c.to_lowercase().next().unwrap_or(c)) + .collect(); + (email.len() >= 3 && email.len() <= 254 && email.contains('@')).then_some(email) +} +fn register(state: &State, headers: &HeaderMap, input: Input) -> Response { + let Some(email) = normalize_email(&input.email) else { + return error(400, "invalid_email", "Enter a valid email address"); + }; + let password = match auth::hash_password(&input.password) { + Ok(v) => v, + Err(e) => return error(400, "invalid_password", &e), + }; + let id = match auth::identifier() { + Ok(v) => v, + Err(_) => return error(500, "internal_error", "Could not create account"), + }; + let db = state.db.lock().unwrap_or_else(|e| e.into_inner()); + if let Err(e) = db.execute( + "INSERT INTO users(id,email,password_hash,created_at) VALUES(?,?,?,?)", + params![id, email, password, timestamp(Utc::now())], + ) { + return if e.to_string().contains("UNIQUE constraint failed") { + error( + 409, + "email_exists", + "An account with this email already exists", + ) + } else { + error(500, "internal_error", "Could not create account") + }; + } + match session(state, &db, &id, &email) { + Ok(s) => session_response(state, headers, 201, s), + Err(_) => error(500, "internal_error", "Could not create session"), + } +} +fn login(state: &State, headers: &HeaderMap, input: Input) -> Response { + let invalid = || error(401, "invalid_credentials", "Email or password is incorrect"); + let Some(email) = normalize_email(&input.email) else { + return invalid(); + }; + let user = state + .db + .lock() + .unwrap_or_else(|e| e.into_inner()) + .query_row( + "SELECT id,password_hash FROM users WHERE email=?", + [&email], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), + ); + let Ok((id, hash)) = user else { + return invalid(); + }; + if !auth::verify_password(&hash, &input.password) { + return invalid(); + } + let db = state.db.lock().unwrap_or_else(|e| e.into_inner()); + match session(state, &db, &id, &email) { + Ok(s) => session_response(state, headers, 200, s), + Err(_) => error(500, "internal_error", "Could not create session"), + } +} +fn refresh(state: &State, headers: &HeaderMap, input: Input) -> Response { + let token = refresh_token(headers, &input.refresh_token); + if token.is_empty() { + return error(400, "refresh_token_required", "Refresh token is required"); + } + let failed = || error(500, "internal_error", "Could not refresh session"); + let invalid = || { + error( + 401, + "invalid_refresh_token", + "Refresh token is invalid or expired", + ) + }; + let mut db = state.db.lock().unwrap_or_else(|e| e.into_inner()); + let Ok(tx) = db.transaction() else { + return failed(); + }; + let user = tx.query_row("SELECT u.id,u.email,t.expires_at FROM refresh_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=? AND t.revoked_at IS NULL",[auth::token_hash(&token)],|r| Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?))).optional(); + let Ok(Some((id, email, expiry))) = user else { + return invalid(); + }; + if !DateTime::parse_from_rfc3339(&expiry.replace(',', ".")).is_ok_and(|v| Utc::now() < v) { + return invalid(); + } + if tx + .execute( + "UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=?", + params![timestamp(Utc::now()), auth::token_hash(&token)], + ) + .is_err() + { + return failed(); + } + let Ok(s) = session(state, &tx, &id, &email) else { + return failed(); + }; + if tx.commit().is_err() { + return failed(); + } + session_response(state, headers, 200, s) +} +struct Session { + value: Value, + expires: DateTime, + access: String, + refresh: String, +} +fn session(state: &State, db: &Connection, id: &str, email: &str) -> Result { + let (access, expires) = state.tokens.access_token(id, email)?; + let refresh = auth::random_token(48)?; + let now = Utc::now(); + db.execute( + "INSERT INTO refresh_tokens(token_hash,user_id,expires_at,created_at) VALUES(?,?,?,?)", + params![ + auth::token_hash(&refresh), + id, + timestamp(now + state.cfg.refresh_ttl), + timestamp(now) + ], + ) + .map_err(|_| "session insert failed")?; + Ok(Session { + value: json!({"accessToken":access,"refreshToken":refresh,"expiresAt":timestamp(expires),"user":{"id":id,"email":email}}), + expires, + access, + refresh, + }) +} +fn session_response(state: &State, headers: &HeaderMap, status: u16, s: Session) -> Response { + let mut r = json_response(status, s.value); + for (name, value, expires) in [ + ("balance_access", s.access, s.expires), + ( + "balance_refresh", + s.refresh, + Utc::now() + state.cfg.refresh_ttl, + ), + ] { + append_cookie( + &mut r, + name, + &value, + expires, + (expires - Utc::now()).num_seconds().max(1), + headers, + ); + } + r +} +fn append_cookie( + r: &mut Response, + name: &str, + value: &str, + expires: DateTime, + max_age: i64, + headers: &HeaderMap, +) { + let time = if expires.timestamp() >= 0 { + SystemTime::UNIX_EPOCH + Duration::from_secs(expires.timestamp() as u64) + } else { + SystemTime::UNIX_EPOCH + }; + let secure = if header(headers, "x-forwarded-proto") + .trim() + .eq_ignore_ascii_case("https") + { + "; Secure" + } else { + "" + }; + let value = format!( + "{name}={value}; Path=/; Expires={}; Max-Age={}; HttpOnly{secure}; SameSite=Strict", + httpdate::fmt_http_date(time), + max_age.max(0) + ); + if let Ok(value) = HeaderValue::from_str(&value) { + r.headers_mut().append("Set-Cookie", value); + } +} +fn header<'a>(headers: &'a HeaderMap, name: &str) -> &'a str { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") +} +fn cookie(headers: &HeaderMap, name: &str) -> String { + for h in headers.get_all("cookie") { + if let Ok(h) = h.to_str() { + for part in h.split(';') { + match part.trim().split_once('=') { + Some((key, value)) if key == name => { + return value.trim_matches('"').trim().to_owned(); + } + _ => {} + } + } + } + } + String::new() +} +fn refresh_token(headers: &HeaderMap, value: &str) -> String { + if value.trim().is_empty() { + cookie(headers, "balance_refresh") + } else { + value.trim().to_owned() + } +} +fn authorize(state: &State, headers: &HeaderMap) -> Result> { + let h = header(headers, "authorization").trim(); + let token = if let Some(token) = h.strip_prefix("Bearer ") { + token.trim().to_owned() + } else { + cookie(headers, "balance_access") + }; + if token.is_empty() { + return Err(Box::new(error( + 401, + "authorization_required", + "Authorization Bearer token is required", + ))); + } + state.tokens.verify(&token).map_err(|_| { + Box::new(error( + 401, + "invalid_token", + "Access token is invalid or expired", + )) + }) +} +fn timestamp(t: DateTime) -> String { + let s = t.to_rfc3339_opts(SecondsFormat::Nanos, true); + format!( + "{}Z", + s.trim_end_matches('Z') + .trim_end_matches('0') + .trim_end_matches('.') + ) +} +fn empty(status: u16) -> Response { + Response::builder() + .status(StatusCode::from_u16(status).unwrap()) + .body(Body::empty()) + .unwrap() +} +fn plain(status: u16, text: &'static str) -> Response { + let mut r = empty(status); + r.headers_mut().insert( + "Content-Type", + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + *r.body_mut() = Body::from(text); + r +} +fn json_response(status: u16, value: Value) -> Response { + let mut r = empty(status); + r.headers_mut().insert( + "Content-Type", + HeaderValue::from_static("application/json; charset=utf-8"), + ); + *r.body_mut() = Body::from(format!("{}\n", value)); + r +} +fn raw_json_response(status: u16, value: Vec) -> Response { + let mut r = empty(status); + r.headers_mut().insert( + "Content-Type", + HeaderValue::from_static("application/json; charset=utf-8"), + ); + *r.body_mut() = Body::from(value); + r +} +fn error(status: u16, code: &str, message: &str) -> Response { + json_response(status, json!({"error":{"code":code,"message":message}})) +} +fn invalid_json() -> Response { + error(400, "invalid_json", "Request body is not valid JSON") +} + +fn clean_path(path: &str, preserve_slash: bool) -> String { + let mut parts = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + _ => parts.push(part), + } + } + let mut result = format!("/{}", parts.join("/")); + if preserve_slash && path.ends_with('/') && result != "/" { + result.push('/'); + } + result +} +fn percent_decode(path: &str) -> String { + let mut bytes = Vec::new(); + let mut i = 0; + let p = path.as_bytes(); + while i < p.len() { + let decoded = if p[i] == b'%' && i + 2 < p.len() { + u8::from_str_radix(&path[i + 1..i + 3], 16).ok() + } else { + None + }; + if let Some(v) = decoded { + bytes.push(v); + i += 3; + continue; + } + bytes.push(p[i]); + i += 1; + } + String::from_utf8_lossy(&bytes).into_owned() +} +fn redirect(location: &str, method: &Method) -> Response { + let mut r = empty(307); + if let Ok(v) = HeaderValue::from_str(location) { + r.headers_mut().insert("Location", v); + } + if method == Method::GET || method == Method::HEAD { + r.headers_mut().insert( + "Content-Type", + HeaderValue::from_static("text/html; charset=utf-8"), + ); + } + if method == Method::GET { + let escaped = location + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'"); + *r.body_mut() = Body::from(format!("Temporary Redirect.\n\n")); + } + r +} +fn web(path: &str, headers: &HeaderMap) -> Response { + if path.starts_with("/v1/") { + return error(404, "not_found", "Endpoint not found"); + } + let cleaned = clean_path(path, false); + let cache = if matches!(cleaned.as_str(), "/" | "/index.html" | "/service-worker.js") { + "no-cache" + } else { + "public, max-age=3600" + }; + if path.ends_with("/index.html") { + let mut r = empty(301); + r.headers_mut() + .insert("Location", HeaderValue::from_static("./")); + r.headers_mut() + .insert("Cache-Control", HeaderValue::from_static(cache)); + return r; + } + let (bytes, mime): (&'static [u8], &'static str) = match cleaned.as_str() { + "/" => ( + include_bytes!("../internal/httpapi/web/index.html"), + "text/html; charset=utf-8", + ), + "/app.js" => ( + include_bytes!("../internal/httpapi/web/app.js"), + "text/javascript; charset=utf-8", + ), + "/service-worker.js" => ( + include_bytes!("../internal/httpapi/web/service-worker.js"), + "text/javascript; charset=utf-8", + ), + "/styles.css" => ( + include_bytes!("../internal/httpapi/web/styles.css"), + "text/css; charset=utf-8", + ), + "/icon.svg" => ( + include_bytes!("../internal/httpapi/web/icon.svg"), + "image/svg+xml", + ), + "/manifest.webmanifest" => ( + include_bytes!("../internal/httpapi/web/manifest.webmanifest"), + "text/plain; charset=utf-8", + ), + _ => return plain(404, "404 page not found\n"), + }; + if path.ends_with('/') && cleaned != "/" { + let mut r = empty(301); + let base = cleaned.rsplit('/').next().unwrap_or(""); + r.headers_mut().insert( + "Location", + HeaderValue::from_str(&format!("../{base}")).unwrap(), + ); + r.headers_mut() + .insert("Cache-Control", HeaderValue::from_static(cache)); + return r; + } + // Go's embedded files have neither ETags nor modification timestamps. + // Wildcard existence checks still apply before serving their content. + let if_match = header(headers, "if-match"); + let if_none_match = header(headers, "if-none-match"); + let precondition = if !if_match.is_empty() + && !if_match + .trim_start_matches([' ', '\t', ',']) + .starts_with('*') + { + Some(412) + } else if if_none_match + .trim_start_matches([' ', '\t', ',']) + .starts_with('*') + { + Some(304) + } else { + None + }; + if let Some(status) = precondition { + let mut r = empty(status); + r.headers_mut() + .insert("Cache-Control", HeaderValue::from_static(cache)); + return r; + } + // Embedded files have no modification time. Any nonempty If-Range thus + // fails its validator and requests the entire representation. + let range = if header(headers, "if-range").is_empty() { + header(headers, "range") + } else { + "" + }; + let ranges = match parse_ranges(range, bytes.len()) { + Ok(r) => r, + Err(overlap) => { + let mut r = plain( + 416, + if overlap { + "invalid range: failed to overlap\n" + } else { + "invalid range\n" + }, + ); + if overlap { + r.headers_mut().insert( + "Content-Range", + HeaderValue::from_str(&format!("bytes */{}", bytes.len())).unwrap(), + ); + } + return r; + } + }; + let mut r = empty(if ranges.is_empty() { 200 } else { 206 }); + r.headers_mut() + .insert("Content-Type", HeaderValue::from_static(mime)); + r.headers_mut() + .insert("Cache-Control", HeaderValue::from_static(cache)); + r.headers_mut() + .insert("Accept-Ranges", HeaderValue::from_static("bytes")); + let content = if let Some(&(start, length)) = ranges.first() { + r.headers_mut().insert( + "Content-Range", + HeaderValue::from_str(&format!( + "bytes {}-{}/{}", + start, + start + length - 1, + bytes.len() + )) + .unwrap(), + ); + &bytes[start..start + length] + } else { + bytes + }; + r.headers_mut().insert( + "Content-Length", + HeaderValue::from_str(&content.len().to_string()).unwrap(), + ); + *r.body_mut() = Body::from(content); + r +} +// The error flag distinguishes malformed syntax from a valid non-overlap. +fn parse_ranges(header: &str, size: usize) -> Result, bool> { + if header.is_empty() { + return Ok(Vec::new()); + } + let range = header.strip_prefix("bytes=").ok_or(false)?; + // The Go FileServer reference ignores multipart ranges. Preserve its + // observable full-representation response for multiple requested ranges. + if range.split(',').count() > 1 { + return Ok(Vec::new()); + } + let mut result = Vec::new(); + let mut no_overlap = false; + for part in range.split(',').map(str::trim).filter(|v| !v.is_empty()) { + let (start, end) = part.split_once('-').ok_or(false)?; + let start = start.trim(); + let end = end.trim(); + let integer = |s: &str| { + s.parse::() + .ok() + .filter(|n| *n >= 0) + .map(|n| n as usize) + }; + if start.is_empty() { + if end.starts_with('-') { + return Err(false); + } + let length = integer(end).ok_or(false)?.min(size); + result.push((size - length, length)); + } else { + let start = integer(start).ok_or(false)?; + if start >= size { + no_overlap = true; + continue; + } + let end = if end.is_empty() { + size - 1 + } else { + integer(end).ok_or(false)?.min(size - 1) + }; + if start > end { + return Err(false); + } + result.push((start, end - start + 1)); + } + } + if result.is_empty() && no_overlap { + return Err(true); + } + if result.iter().map(|(_, n)| *n).sum::() > size { + result.clear(); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + fn state() -> State { + State::new( + Config { + address: "127.0.0.1:0".into(), + database_path: ":memory:".into(), + jwt_secret: "test secret".into(), + access_ttl: chrono::TimeDelta::minutes(15), + refresh_ttl: chrono::TimeDelta::days(30), + allow_registration: true, + cors_origins: vec!["https://allowed.example".into()], + }, + Mutex::new(crate::database::open(":memory:").unwrap()), + ) + } + #[test] + fn strict_go_auth_json_defaults_duplicates_and_trailing_document() { + assert!(decode(b"null", true).unwrap().email.is_empty()); + let r = decode( + br#"{"EMAIL":"first","email":null,"PASSWORD":"pw"} {}"#, + true, + ) + .unwrap(); + assert_eq!(r.email, "first"); + assert_eq!(r.password, "pw"); + for invalid in [ + br#"{"extra":null}"#.as_slice(), + br#"{"email":12}"#, + br#"[]"#, + ] { + assert!(decode(invalid, true).is_err()); + } + assert!(decode(br#"{"email":"a@b"}"#, false).is_err()); + } + #[tokio::test] + async fn security_cors_and_head_apply_to_all_responses() { + let state = Arc::new(state()); + let r = handle( + ExtractState(state.clone()), + Request::builder() + .method("OPTIONS") + .uri("/v1/sync") + .header("Origin", "https://allowed.example") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(r.status(), StatusCode::NO_CONTENT); + assert_eq!(r.headers()["Cache-Control"], "no-store"); + assert_eq!( + r.headers()["Access-Control-Allow-Origin"], + "https://allowed.example" + ); + assert_eq!(r.headers()["X-Frame-Options"], "DENY"); + let r = handle( + ExtractState(state), + Request::builder() + .method("HEAD") + .uri("/app.js") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(r.status(), StatusCode::OK); + assert_eq!(r.headers()["Cache-Control"], "public, max-age=3600"); + assert!( + axum::body::to_bytes(r.into_body(), usize::MAX) + .await + .unwrap() + .is_empty() + ); + } + #[test] + fn limiter_is_shared_across_auth_endpoints_and_checks_before_json() { + let state = state(); + let headers = HeaderMap::new(); + for _ in 0..30 { + assert_eq!( + dispatch( + &state, + &Method::POST, + "/v1/auth/login", + &headers, + Ok(b"{"), + "ip" + ) + .status(), + StatusCode::BAD_REQUEST + ); + } + let r = dispatch( + &state, + &Method::POST, + "/v1/auth/register", + &headers, + Ok(b"{"), + "ip", + ); + assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(r.headers()["Retry-After"], "60"); + assert_eq!( + dispatch( + &state, + &Method::POST, + "/v1/auth/login", + &headers, + Ok(b"{"), + "other" + ) + .status(), + StatusCode::BAD_REQUEST + ); + } + #[test] + fn bearer_precedence_cookie_fallback_and_static_range() { + let state = state(); + let (token, _) = state.tokens.access_token("u", "u@example.com").unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + "Cookie", + HeaderValue::from_str(&format!("balance_access={token}")).unwrap(), + ); + headers.insert("Authorization", HeaderValue::from_static("Basic ignored")); + assert_eq!(authorize(&state, &headers).unwrap().subject, "u"); + headers.insert("Authorization", HeaderValue::from_static("Bearer bad")); + assert_eq!( + authorize(&state, &headers).unwrap_err().status(), + StatusCode::UNAUTHORIZED + ); + headers.insert("Range", HeaderValue::from_static("bytes=0-2")); + let r = web("/app.js", &headers); + assert_eq!(r.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(r.headers()["Content-Length"], "3"); + } + #[test] + fn multi_range_matches_go_reference_and_malformed_ranges_stay_errors() { + let mut headers = HeaderMap::new(); + headers.insert("Range", HeaderValue::from_static("bytes=0-0,2-2")); + let r = web("/icon.svg", &headers); + assert_eq!(r.status(), StatusCode::OK); + assert_eq!(r.headers()["Content-Type"], "image/svg+xml"); + headers.insert("Range", HeaderValue::from_static("bytes=10-1")); + assert_eq!( + web("/icon.svg", &headers).status(), + StatusCode::RANGE_NOT_SATISFIABLE + ); + headers.insert("Range", HeaderValue::from_static("items=0-1")); + assert_eq!( + web("/icon.svg", &headers).status(), + StatusCode::RANGE_NOT_SATISFIABLE + ); + } +} diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..95223e6 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,779 @@ +use chrono::{DateTime, Duration, NaiveDate, SecondsFormat, Utc}; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; +use serde::{ + Deserialize, Deserializer, + de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}, +}; +#[cfg(test)] +use serde_json::Value; +use serde_json::{json, value::RawValue}; +use std::fmt; + +pub type SyncError = (u16, &'static str, String); + +#[derive(Default)] +struct Request { + cursor: i64, + changes: Vec, + pull: Option, +} + +#[derive(Default)] +struct Change { + entity: String, + id: String, + deleted: bool, + updated_at: Option>, + payload: Option>, +} + +// Go's decoder accepts null for ordinary scalar fields, matches keys ignoring +// case, and lets the last duplicate field win. Keep those wire semantics. +impl<'de> Deserialize<'de> for Request { + fn deserialize>(d: D) -> Result { + struct RequestVisitor; + impl<'de> Visitor<'de> for RequestVisitor { + type Value = Request; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a sync request") + } + fn visit_unit(self) -> Result { + Ok(Request::default()) + } + fn visit_map>(self, mut m: M) -> Result { + let mut r = Request::default(); + while let Some(key) = m.next_key::()? { + match fold_key(&key).as_str() { + "cursor" => { + if let Some(v) = m.next_value::>()? { + r.cursor = v; + } + } + "deviceid" => { + let _: Option = m.next_value()?; + } + "changes" => m.next_value_seed(ChangesSeed(&mut r.changes))?, + "pull" => r.pull = m.next_value()?, + _ => return Err(de::Error::custom("unknown field")), + } + } + Ok(r) + } + } + d.deserialize_any(RequestVisitor) + } +} + +// encoding/json reuses existing slice elements when a duplicate changes field +// appears. Missing fields in those elements keep their preceding values. +struct ChangesSeed<'a>(&'a mut Vec); +impl<'de> DeserializeSeed<'de> for ChangesSeed<'_> { + type Value = (); + fn deserialize>(self, d: D) -> Result<(), D::Error> { + struct ChangesVisitor<'a>(&'a mut Vec); + impl<'de> Visitor<'de> for ChangesVisitor<'_> { + type Value = (); + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("an array of changes") + } + fn visit_unit(self) -> Result<(), E> { + self.0.clear(); + Ok(()) + } + fn visit_seq>(self, mut seq: S) -> Result<(), S::Error> { + let mut index = 0; + loop { + if index == self.0.len() { + self.0.push(Change::default()); + } + if seq + .next_element_seed(ChangeSeed(&mut self.0[index]))? + .is_none() + { + break; + } + index += 1; + } + self.0.truncate(index); + Ok(()) + } + } + d.deserialize_any(ChangesVisitor(self.0)) + } +} + +struct ChangeSeed<'a>(&'a mut Change); +impl<'de> DeserializeSeed<'de> for ChangeSeed<'_> { + type Value = (); + fn deserialize>(self, d: D) -> Result<(), D::Error> { + struct ChangeVisitor<'a>(&'a mut Change); + impl<'de> Visitor<'de> for ChangeVisitor<'_> { + type Value = (); + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a sync change") + } + fn visit_unit(self) -> Result<(), E> { + Ok(()) + } + fn visit_map>(self, mut m: M) -> Result<(), M::Error> { + let c = self.0; + while let Some(key) = m.next_key::()? { + match fold_key(&key).as_str() { + "entity" => { + if let Some(v) = m.next_value::>()? { + c.entity = v; + } + } + "id" => { + if let Some(v) = m.next_value::>()? { + c.id = v; + } + } + "deleted" => { + if let Some(v) = m.next_value::>()? { + c.deleted = v; + } + } + "updatedat" => { + // time.Time.UnmarshalJSON reads the quoted bytes directly; + // JSON escapes inside a timestamp are not unescaped by Go. + let raw = m.next_value::>()?; + if raw.get() != "null" { + let value = raw + .get() + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .ok_or_else(|| de::Error::custom("invalid time"))?; + c.updated_at = Some(parse_time(value).map_err(de::Error::custom)?); + } + } + "payload" => { + c.payload = + Some(m.next_value::>()?.get().as_bytes().to_vec()); + } + "version" | "sequence" => { + let _: Option = m.next_value()?; + } + _ => return Err(de::Error::custom("unknown field")), + } + } + Ok(()) + } + } + d.deserialize_any(ChangeVisitor(self.0)) + } +} + +fn fold_key(s: &str) -> String { + // Unicode simple folding in encoding/json also equates long s with S. + s.chars() + .flat_map(|c| if c == 'ſ' { 's' } else { c }.to_lowercase()) + .collect() +} + +fn parse_time(s: &str) -> Result, &'static str> { + // Match Go's RFC3339 parser, including its currently enabled fallback: + // one-digit hours, comma fractions, offsets up to 24:60, nanosecond + // truncation, and rejection of leap seconds and lowercase t/z. + let invalid = "invalid RFC3339 timestamp"; + if !s.is_ascii() { + return Err(invalid); + } + let (date, time) = s.split_once('T').ok_or(invalid)?; + let number = |v: &str, width: usize| -> Result { + if v.len() != width || !v.bytes().all(|b| b.is_ascii_digit()) { + return Err(invalid); + } + v.parse().map_err(|_| invalid) + }; + if date.len() != 10 { + return Err(invalid); + } + let mut date_parts = date.split('-'); + let year = number(date_parts.next().ok_or(invalid)?, 4)?; + let month = number(date_parts.next().ok_or(invalid)?, 2)?; + let day = number(date_parts.next().ok_or(invalid)?, 2)?; + if date_parts.next().is_some() { + return Err(invalid); + } + let (clock, offset) = if let Some(clock) = time.strip_suffix('Z') { + (clock, 0i64) + } else { + if time.len() < 6 { + return Err(invalid); + } + let (clock, zone) = time.split_at(time.len() - 6); + if !matches!(zone.as_bytes()[0], b'+' | b'-') || zone.as_bytes()[3] != b':' { + return Err(invalid); + } + let hours = number(&zone[1..3], 2)?; + let minutes = number(&zone[4..6], 2)?; + if hours > 24 || minutes > 60 { + return Err(invalid); + } + let sign = if zone.starts_with('-') { -1 } else { 1 }; + (clock, sign * i64::from(hours * 3600 + minutes * 60)) + }; + let (clock, nanos) = if let Some(index) = clock.find(['.', ',']) { + let fraction = &clock[index + 1..]; + if fraction.is_empty() || !fraction.bytes().all(|b| b.is_ascii_digit()) { + return Err(invalid); + } + let precision = fraction.len().min(9); + let nanos = number(&fraction[..precision], precision)? * 10u32.pow((9 - precision) as u32); + (&clock[..index], nanos) + } else { + (clock, 0) + }; + let mut parts = clock.split(':'); + let hour = parts.next().ok_or(invalid)?; + if !(1..=2).contains(&hour.len()) { + return Err(invalid); + } + let hour = number(hour, hour.len())?; + let minute = number(parts.next().ok_or(invalid)?, 2)?; + let second = number(parts.next().ok_or(invalid)?, 2)?; + if parts.next().is_some() || second > 59 { + return Err(invalid); + } + let local = NaiveDate::from_ymd_opt(year as i32, month, day) + .and_then(|d| d.and_hms_nano_opt(hour, minute, second, nanos)) + .ok_or(invalid)?; + local + .and_utc() + .checked_sub_signed(Duration::seconds(offset)) + .ok_or(invalid) +} + +fn timestamp(t: DateTime) -> String { + // SecondsFormat::AutoSi pads to 3/6/9 digits, whereas Go strips every zero. + let s = t.to_rfc3339_opts(SecondsFormat::Nanos, true); + let s = s + .trim_end_matches('Z') + .trim_end_matches('0') + .trim_end_matches('.'); + format!("{s}Z") +} + +fn failure(status: u16, code: &'static str, message: &str) -> SyncError { + (status, code, message.to_owned()) +} + +pub fn synchronize_wire( + db: &mut Connection, + user_id: &str, + body: &[u8], +) -> Result, SyncError> { + let invalid_json = || failure(400, "invalid_json", "Request body is not valid JSON"); + let body = &body[..body.len().min(2 << 20)]; + // The original decoder reads the first JSON value, without requiring EOF. + let (masked, raw_payloads) = + crate::go_json::extract_raw_fields(body, "payload").map_err(|_| invalid_json())?; + let normalized = crate::go_json::normalize(&masked); + let mut request = Request::deserialize(&mut serde_json::Deserializer::from_slice(&normalized)) + .map_err(|_| invalid_json())?; + for change in &mut request.changes { + if let Some(placeholder) = &change.payload { + let index: String = serde_json::from_slice(placeholder).map_err(|_| invalid_json())?; + let index: usize = index.parse().map_err(|_| invalid_json())?; + change.payload = Some(raw_payloads.get(index).ok_or_else(invalid_json)?.clone()); + } + } + if request.cursor < 0 || request.changes.len() > 5000 { + return Err(failure( + 400, + "invalid_sync_request", + "Cursor or change count is invalid", + )); + } + for c in &request.changes { + let message = if !matches!(c.entity.as_str(), "transaction" | "category" | "budget") { + Some("entity must be transaction, category, or budget") + } else if c.id.is_empty() || c.id.len() > 128 { + Some("record id is invalid") + } else if c.updated_at.is_none() || c.updated_at == parse_time("0001-01-01T00:00:00Z").ok() + { + Some("updatedAt is required") + } else if c + .updated_at + .is_some_and(|time| time > Utc::now() + Duration::hours(24)) + { + Some("updatedAt is too far in the future") + } else if !c.deleted && c.payload.is_none() { + Some("payload is required for active records") + } else if c.payload.as_ref().is_some_and(|p| p.len() > 256 * 1024) { + Some("payload is too large") + } else { + None + }; + if let Some(message) = message { + return Err(failure(400, "invalid_change", message)); + } + } + let tx = db + .transaction() + .map_err(|_| failure(500, "sync_failed", "Could not begin synchronization"))?; + for c in request.changes { + apply(&tx, user_id, c) + .map_err(|_| failure(500, "sync_failed", "Could not apply changes"))?; + } + let (changes, cursor, more) = if request.pull.unwrap_or(true) { + load(&tx, user_id, request.cursor) + .map_err(|_| failure(500, "sync_failed", "Could not load changes"))? + } else { + (Vec::new(), request.cursor, false) + }; + tx.commit() + .map_err(|_| failure(500, "sync_failed", "Could not complete synchronization"))?; + let mut response = format!("{{\"cursor\":{cursor},\"changes\":[").into_bytes(); + for (index, change) in changes.iter().enumerate() { + if index != 0 { + response.push(b','); + } + response.extend_from_slice(change); + } + response.extend_from_slice( + format!( + "],\"hasMore\":{more},\"serverTime\":{}}}\n", + serde_json::to_string(×tamp(Utc::now())).expect("timestamp JSON") + ) + .as_bytes(), + ); + Ok(response) +} + +#[cfg(test)] +fn synchronize(db: &mut Connection, user_id: &str, body: &[u8]) -> Result { + let response = synchronize_wire(db, user_id, body)?; + serde_json::from_slice(&crate::go_json::normalize(&response)) + .map_err(|_| failure(500, "sync_failed", "Could not serialize synchronization")) +} + +type InternalError = Box; + +fn apply(tx: &Transaction<'_>, user: &str, c: Change) -> Result<(), InternalError> { + let current: Option<(String, i64)> = tx + .query_row( + "SELECT updated_at,version FROM records WHERE user_id=? AND entity=? AND record_id=?", + params![user, c.entity, c.id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + let updated = c.updated_at.expect("validated timestamp"); + let mut version = 1; + if let Some((time, old_version)) = current { + if updated <= parse_time(&time)? { + return Ok(()); + } + version = old_version.wrapping_add(1); + } + let payload = if c.deleted { + None + } else { + c.payload.as_deref() + }; + let time = timestamp(updated); + tx.execute("INSERT INTO records(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,CAST(? AS TEXT),?,?,?) ON CONFLICT(user_id,entity,record_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at,deleted=excluded.deleted,version=excluded.version", params![user,c.entity,c.id,payload,time,c.deleted,version])?; + tx.execute("INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,CAST(? AS TEXT),?,?,?)", params![user,c.entity,c.id,payload,time,c.deleted,version])?; + Ok(()) +} + +fn load( + tx: &Transaction<'_>, + user: &str, + after: i64, +) -> Result<(Vec>, i64, bool), InternalError> { + let mut stmt = tx.prepare("SELECT sequence,entity,record_id,payload,updated_at,deleted,version FROM changes WHERE user_id=? AND sequence>? ORDER BY sequence ASC LIMIT ?")?; + let mut rows = stmt.query(params![user, after, 501])?; + let mut result = Vec::new(); + let mut cursor = after; + let mut more = false; + while let Some(r) = rows.next()? { + if result.len() == 500 { + more = true; + break; + } + let sequence: i64 = r.get(0)?; + let payload: Option> = match r.get_ref(3)? { + rusqlite::types::ValueRef::Null => None, + rusqlite::types::ValueRef::Text(bytes) | rusqlite::types::ValueRef::Blob(bytes) => { + Some(bytes.to_vec()) + } + _ => return Err("invalid database payload".into()), + }; + let time: String = r.get(4)?; + let version: i64 = r.get(6)?; + let mut item = json!({"entity": r.get::<_, String>(1)?, "id": r.get::<_, String>(2)?, "updatedAt": timestamp(parse_time(&time)?), "deleted": r.get::<_, bool>(5)?}); + if sequence != 0 { + item["sequence"] = json!(sequence); + } + if version != 0 { + item["version"] = json!(version); + } + let mut encoded = serde_json::to_vec(&item)?; + if let Some(p) = payload { + serde_json::from_slice::>(&crate::go_json::normalize(&p))?; + encoded.pop(); + encoded.extend_from_slice(b",\"payload\":"); + encoded.extend_from_slice(&crate::go_json::compact_raw(&p)); + encoded.push(b'}'); + } + result.push(encoded); + cursor = sequence; + } + Ok((result, cursor, more)) +} + +#[cfg(test)] +mod tests { + use super::*; + fn db() -> Connection { + let db = crate::database::open(":memory:").unwrap(); + db.execute( + "INSERT INTO users VALUES('u','u@example.com','hash','now')", + [], + ) + .unwrap(); + db.execute( + "INSERT INTO users VALUES('v','v@example.com','hash','now')", + [], + ) + .unwrap(); + db + } + fn change(id: &str, time: &str) -> Value { + json!({"entity":"category","id":id,"updatedAt":time,"payload":{"name":"Food"}}) + } + fn sync(db: &mut Connection, body: Value) -> Result { + synchronize(db, "u", &serde_json::to_vec(&body).unwrap()) + } + + #[test] + fn last_write_wins_and_replay_does_not_advance_sequence() { + let mut db = db(); + let original = change("c", "2025-01-01T01:00:00+01:00"); + let first = sync(&mut db, json!({"changes":[original.clone()]})).unwrap(); + assert_eq!(first["cursor"], 1); + assert_eq!(first["changes"][0]["updatedAt"], "2025-01-01T00:00:00Z"); + for c in [original, change("c", "2024-01-01T00:00:00Z")] { + let r = sync(&mut db, json!({"cursor":1,"changes":[c]})).unwrap(); + assert_eq!(r["cursor"], 1); + assert_eq!(r["changes"], json!([])); + } + let deleted = json!({"entity":"category","id":"c","updatedAt":"2025-02-01T00:00:00.123400000Z","deleted":true,"payload":{"ignored":true}}); + let r = sync(&mut db, json!({"cursor":1,"changes":[deleted]})).unwrap(); + assert_eq!(r["changes"][0]["version"], 2); + assert_eq!(r["changes"][0]["deleted"], true); + assert!(r["changes"][0].get("payload").is_none()); + assert_eq!(r["changes"][0]["updatedAt"], "2025-02-01T00:00:00.1234Z"); + } + + #[test] + fn pagination_and_user_isolation_preserve_global_sequence() { + let mut db = db(); + let changes: Vec = (0..501) + .map(|i| change(&i.to_string(), "2025-01-01T00:00:00Z")) + .collect(); + let r = sync(&mut db, json!({"changes":changes,"pull":false,"cursor":7})).unwrap(); + assert_eq!(r["cursor"], 7); + assert_eq!(r["changes"], json!([])); + let r = sync(&mut db, json!({})).unwrap(); + assert_eq!(r["changes"].as_array().unwrap().len(), 500); + assert_eq!(r["cursor"], 500); + assert_eq!(r["hasMore"], true); + let r = sync(&mut db, json!({"cursor":500})).unwrap(); + assert_eq!(r["changes"].as_array().unwrap().len(), 1); + assert_eq!(r["cursor"], 501); + assert_eq!(r["hasMore"], false); + let r = synchronize(&mut db, "v", b"{}").unwrap(); + assert_eq!(r["changes"], json!([])); + assert_eq!(r["cursor"], 0); + } + + #[test] + fn all_pushes_roll_back_on_apply_or_pull_failure() { + let mut db = db(); + db.execute_batch("CREATE TRIGGER reject_bad BEFORE INSERT ON records WHEN NEW.record_id='bad' BEGIN SELECT RAISE(ABORT, 'reject'); END;").unwrap(); + let r = sync( + &mut db, + json!({"changes":[change("good","2025-01-01T00:00:00Z"),change("bad","2025-01-01T00:00:00Z")]}), + ); + assert_eq!(r.unwrap_err().2, "Could not apply changes"); + assert_eq!( + db.query_row("SELECT count(*) FROM records", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 0 + ); + db.execute("INSERT INTO changes(user_id,entity,record_id,updated_at,deleted,version) VALUES('u','category','broken','invalid-time',1,1)", []).unwrap(); + assert_eq!( + sync( + &mut db, + json!({"changes":[change("good","2025-01-01T00:00:00Z")]}) + ) + .unwrap_err() + .2, + "Could not load changes" + ); + assert_eq!( + db.query_row("SELECT count(*) FROM records", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 0 + ); + } + + #[test] + fn strict_fields_and_go_null_defaults_and_validation() { + let mut db = db(); + for raw in [ + b"null".as_slice(), + b"{\"cursor\":null,\"deviceId\":null,\"changes\":null,\"pull\":null}", + b"{} {}", + ] { + assert!(synchronize(&mut db, "u", raw).is_ok()); + } + for raw in [ + b"{\"extra\":1}".as_slice(), + b"{\"changes\":[{\"extra\":1}]}", + b"{\"cursor\":1.0}", + b"{\"changes\":{}}", + ] { + assert_eq!( + synchronize(&mut db, "u", raw).unwrap_err().1, + "invalid_json" + ); + } + let raw = br#"{"CHANGES":[{"ENTITY":"category","ID":"c","updatedAt":"2025-01-01T00:00:00Z","payload":null}]}"#; + let r = synchronize(&mut db, "u", raw).unwrap(); + assert!(r["changes"][0].get("payload").unwrap().is_null()); + assert_eq!( + sync(&mut db, json!({"cursor":-1})).unwrap_err().1, + "invalid_sync_request" + ); + assert_eq!( + sync(&mut db, json!({"changes":[null]})).unwrap_err().2, + "entity must be transaction, category, or budget" + ); + let mut c = change("c", "0001-01-01T00:00:00Z"); + assert_eq!( + sync(&mut db, json!({"changes":[c.clone()]})).unwrap_err().2, + "updatedAt is required" + ); + c["updatedAt"] = json!(timestamp(Utc::now() + Duration::hours(25))); + assert_eq!( + sync(&mut db, json!({"changes":[c]})).unwrap_err().2, + "updatedAt is too far in the future" + ); + let mut c = change("c", "2025-01-01T00:00:00Z"); + c["payload"] = json!("x".repeat(256 * 1024)); + assert_eq!( + sync(&mut db, json!({"changes":[c]})).unwrap_err().2, + "payload is too large" + ); + } + #[test] + fn go_timestamp_parser_accepts_fallbacks_and_rejects_chrono_extensions() { + for (input, expected) in [ + ("2025-01-02T3:04:05Z", "2025-01-02T03:04:05Z"), + ( + "2025-01-02T03:04:05,123456789123Z", + "2025-01-02T03:04:05.123456789Z", + ), + ("2025-01-02T03:04:05+24:60", "2025-01-01T02:04:05Z"), + ("0000-01-01T00:00:00Z", "0000-01-01T00:00:00Z"), + ] { + assert_eq!(timestamp(parse_time(input).unwrap()), expected, "{input}"); + } + for input in [ + "2025-01-02t03:04:05Z", + "2025-01-02T03:04:05z", + "2025-01-02T03:04:60Z", + "2025-01-02 03:04:05Z", + "2025-01-02T03:04:05+25:00", + "2025-01-02T03:04:05+01:61", + "2025-01-02T03:04:05.Z", + "2025-02-29T00:00:00Z", + ] { + assert!(parse_time(input).is_err(), "{input}"); + } + let mut db = db(); + let raw = br#"{"changes":[{"entity":"category","id":"c","updatedAt":"\u0032025-01-01T00:00:00Z","payload":null}]}"#; + assert_eq!( + synchronize(&mut db, "u", raw).unwrap_err().1, + "invalid_json" + ); + } + + #[test] + fn duplicate_fields_reuse_go_slice_elements_and_null_keeps_scalars() { + let mut db = db(); + let raw = br#"{"changes":[{"entity":"category","id":"first","updatedAt":"2025-01-01T00:00:00Z","payload":null}],"changes":[{"id":"second","entity":null,"updatedAt":null}]}"#; + let result = synchronize(&mut db, "u", raw).unwrap(); + assert_eq!(result["changes"][0]["id"], "second"); + assert_eq!(result["changes"][0]["entity"], "category"); + let raw = r#"{"curſor":1,"CHANGES":[{"entity":"category","id":"third","updatedAt":"2025-01-02T00:00:00Z","payload":true}]}"#; + let result = synchronize(&mut db, "u", raw.as_bytes()).unwrap(); + assert_eq!(result["changes"][0]["id"], "third"); + assert_eq!(result["changes"][0]["payload"], true); + } + + #[test] + fn exact_payload_numbers_and_database_raw_text_are_preserved() { + let mut db = db(); + let payload = r#"{ "large": 123456789012345678901234567890, "decimal": 0.123456789012345678901234567890, "exponent": 1e999 }"#; + let body = format!( + r#"{{"changes":[{{"entity":"transaction","id":"t","updatedAt":"2025-01-01T00:00:00Z","payload":{payload}}}]}}"# + ); + let result = synchronize(&mut db, "u", body.as_bytes()).unwrap(); + let stored: String = db + .query_row("SELECT payload FROM records", [], |r| r.get(0)) + .unwrap(); + assert_eq!(stored, payload); + let serialized = serde_json::to_string(&result["changes"][0]["payload"]).unwrap(); + assert!(serialized.contains("123456789012345678901234567890")); + assert!(serialized.contains("0.123456789012345678901234567890")); + assert!(serialized.to_lowercase().contains("1e+999") || serialized.contains("1e999")); + } + + #[test] + fn validation_limits_use_bytes_and_validate_whole_batch_before_writing() { + let mut db = db(); + let mut invalid = change(&"я".repeat(65), "2025-01-01T00:00:00Z"); + assert_eq!( + sync( + &mut db, + json!({"changes":[change("good","2025-01-01T00:00:00Z"),invalid.clone()]}) + ) + .unwrap_err() + .2, + "record id is invalid" + ); + assert_eq!( + db.query_row("SELECT count(*) FROM records", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 0 + ); + invalid["id"] = json!("я".repeat(64)); + assert!(sync(&mut db, json!({"changes":[invalid]})).is_ok()); + let changes: Vec<_> = (0..5001) + .map(|_| change("c", "2025-01-01T00:00:00Z")) + .collect(); + assert_eq!( + sync(&mut db, json!({"changes":changes})).unwrap_err().1, + "invalid_sync_request" + ); + let raw = format!( + r#"{{"changes":[{{"entity":"category","id":"large","updatedAt":"2025-01-01T00:00:00Z","payload":{{ {} }} }}]}}"#, + " ".repeat(256 * 1024) + ); + assert_eq!( + synchronize(&mut db, "u", raw.as_bytes()).unwrap_err().2, + "payload is too large" + ); + let mut trailing = b"{} ".to_vec(); + trailing.resize((2 << 20) + 100, b' '); + assert!(synchronize(&mut db, "u", &trailing).is_ok()); + } + + #[test] + fn wire_preserves_raw_payload_duplicates_unicode_escapes_and_invalid_utf8() { + let mut db = db(); + let payload = b"{ \"duplicate\":1, \"duplicate\":2, \"surrogate\":\"\\ud800\", \"invalid\":\"\xff\", \"html\":\"<&>\" }"; + let mut body = br#"{"changes":[{"entity":"category","id":"\ud800","updatedAt":"2025-01-01T00:00:00Z","payload":"#.to_vec(); + body.extend_from_slice(payload); + body.extend_from_slice(b"}]}"); + let response = synchronize_wire(&mut db, "u", &body).unwrap(); + let id = "\"id\":\"�\"".as_bytes(); + assert!(response.windows(id.len()).any(|w| w == id)); + let expected = b"\"payload\":{\"duplicate\":1,\"duplicate\":2,\"surrogate\":\"\\ud800\",\"invalid\":\"\xff\",\"html\":\"\\u003c\\u0026\\u003e\"}"; + assert!(response.windows(expected.len()).any(|w| w == expected)); + let stored: Vec = db + .query_row("SELECT CAST(payload AS BLOB) FROM records", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(stored, payload); + let storage_type: String = db + .query_row("SELECT typeof(payload) FROM records", [], |r| r.get(0)) + .unwrap(); + assert_eq!(storage_type, "text"); + let pulled = synchronize_wire(&mut db, "u", b"{}").unwrap(); + assert!(pulled.windows(expected.len()).any(|w| w == expected)); + } + + #[test] + fn raw_payload_limits_count_original_bytes_before_unicode_replacement() { + let mut db = db(); + let mut body = br#"{"changes":[{"entity":"category","id":"c","updatedAt":"2025-01-01T00:00:00Z","payload":""#.to_vec(); + body.extend(std::iter::repeat_n(0xff, 256 * 1024 - 2)); + body.extend_from_slice(b"\"}]}"); + assert!(synchronize_wire(&mut db, "u", &body).is_ok()); + let body = b"{\"changes\":[{\"entity\":\"category\",\"id\":\"\xff\",\"updatedAt\":\"2025-01-02T00:00:00Z\",\"payload\":null}]}"; + assert!(synchronize_wire(&mut db, "u", body).is_ok()); + } + + #[test] + fn tombstone_resurrection_and_sequential_batch_conflicts_keep_history() { + let mut db = db(); + let initial = change("c", "2025-01-01T00:00:00Z"); + let tombstone = + json!({"entity":"category","id":"c","updatedAt":"2025-01-02T00:00:00Z","deleted":true}); + let newer = change("c", "2025-01-03T00:00:00Z"); + let result = sync( + &mut db, + json!({"changes":[initial.clone(),tombstone.clone(),initial,newer.clone(),newer]}), + ) + .unwrap(); + let changes = result["changes"].as_array().unwrap(); + assert_eq!(changes.len(), 3); + assert_eq!(changes[0]["version"], 1); + assert_eq!(changes[1]["version"], 2); + assert_eq!(changes[2]["version"], 3); + assert_eq!(changes[2]["deleted"], false); + assert!(changes[1].get("payload").is_none()); + let isolated = synchronize( + &mut db, + "v", + &serde_json::to_vec(&json!({"changes":[tombstone]})).unwrap(), + ) + .unwrap(); + assert_eq!(isolated["cursor"], 4); + let user = sync(&mut db, json!({"cursor":3})).unwrap(); + assert_eq!(user["cursor"], 3); + assert_eq!(user["changes"], json!([])); + } + + #[test] + fn concurrent_device_retries_have_one_winner_and_unique_sequences() { + use std::sync::{Arc, Barrier, Mutex}; + let db = Arc::new(Mutex::new(db())); + let barrier = Arc::new(Barrier::new(8)); + let mut handles = Vec::new(); + for index in 0..8 { + let db = Arc::clone(&db); + let barrier = Arc::clone(&barrier); + handles.push(std::thread::spawn(move || { + barrier.wait(); + let c = change("shared", &format!("2025-01-01T00:00:0{index}Z")); + let mut db = db.lock().unwrap(); + sync(&mut db, json!({"changes":[c.clone(), c],"pull":false})).unwrap(); + })); + } + for handle in handles { + handle.join().unwrap(); + } + let mut db = db.lock().unwrap(); + let (time, version): (String, i64) = db + .query_row("SELECT updated_at,version FROM records", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert_eq!(time, "2025-01-01T00:00:07Z"); + let result = sync(&mut db, json!({})).unwrap(); + let changes = result["changes"].as_array().unwrap(); + assert_eq!(changes.len() as i64, version); + for (index, change) in changes.iter().enumerate() { + assert_eq!(change["sequence"], (index + 1) as i64); + assert_eq!(change["version"], (index + 1) as i64); + } + } +} diff --git a/tools/compatibility.py b/tools/compatibility.py new file mode 100644 index 0000000..f68bb49 --- /dev/null +++ b/tools/compatibility.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +"""Black-box Go/Rust compatibility and bilateral SQLite takeover checks (stdlib only).""" +import argparse +import base64 +import concurrent.futures +import contextlib +import http.client +import hashlib +import hmac +import json +import os +from pathlib import Path +import socket +import sqlite3 +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +SECRET = 'compatibility-test-secret-not-for-deployment-0123456789' +PASSWORD = 'compatibility password' + + +class Server: + def __init__(self, binary, database, **extra): + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + self.port = sock.getsockname()[1] + env = os.environ.copy() + env.update(BALANCE_ADDR=f'127.0.0.1:{self.port}', BALANCE_DB_PATH=str(database), + BALANCE_JWT_SECRET=SECRET, BALANCE_ACCESS_TTL='15m', + BALANCE_REFRESH_TTL='720h', BALANCE_ALLOW_REGISTRATION='true', + BALANCE_CORS_ORIGINS='https://allowed.example') + env.update(extra) + self.log = tempfile.TemporaryFile() + self.process = subprocess.Popen([str(binary)], env=env, cwd=ROOT, + stdout=self.log, stderr=self.log) + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + if self.process.poll() is not None: + self.log.seek(0) + raise AssertionError(f'{binary.name} exited: {self.log.read().decode()}') + try: + if self.request('GET', '/health')[0] == 200: + return + except (OSError, http.client.HTTPException): + pass + time.sleep(.05) + self.close() + raise AssertionError(f'{binary.name} startup timeout') + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self.log.close() + + def request(self, method, path, body=None, token=None, headers=None, raw=None, decode_json=True): + hdr = dict(headers or {}) + if token: + hdr['Authorization'] = f'Bearer {token}' + if body is not None: + raw = json.dumps(body).encode() + hdr['Content-Type'] = 'application/json' + connection = http.client.HTTPConnection('127.0.0.1', self.port, timeout=30) + try: + connection.request(method, path, body=raw, headers=hdr) + response = connection.getresponse() + data = response.read() + values = response.getheaders() + content_type = response.getheader('Content-Type', '') + if decode_json and data and 'application/json' in content_type: + data = json.loads(data) + return response.status, values, data + finally: + connection.close() + + +@contextlib.contextmanager +def running(binary, database, **extra): + server = Server(binary, database, **extra) + try: + yield server + finally: + server.close() + + +def expect(result, status): + assert result[0] == status, f'expected HTTP {status}, got {result[0]}: {result[2]!r}' + return result[2] + + +def credentials(email='User@Example.COM'): + return {'email': email, 'password': PASSWORD} + + +def change(identifier, stamp='2025-01-01T00:00:00Z', **extra): + result = dict(entity='transaction', id=identifier, updatedAt=stamp, + payload={'amount': 125.5, 'note': 'Тест €', 'nested': [None, True, 1]}) + result.update(extra) + return result + + +def sync(server, token, changes=(), cursor=0, **extra): + return expect(server.request('POST', '/v1/sync', + dict(cursor=cursor, deviceId='compatibility', changes=list(changes), **extra), token), 200) + + +def scenario(binary, database): + """Return deterministic observable results; assertions check generated sessions too.""" + observed = {} + with running(binary, database) as server: + for method, path, body in [ + ('GET', '/v1/me', None), ('GET', '/v1/missing', None), + ('POST', '/v1/auth/register', {'email': 'bad', 'password': PASSWORD}), + ('POST', '/v1/auth/register', {'email': 'a@b', 'password': 'short'}), + ('POST', '/v1/auth/login', credentials()), + ('POST', '/v1/auth/refresh', {}), + ('POST', '/v1/auth/refresh', {'refreshToken': 'bad'}), + ('POST', '/v1/auth/login', {'unknown': 1}), + ]: + result = server.request(method, path, body) + observed[f'{method} {path} {body}'] = (result[0], result[2]) + for path in ['/','/app.js','/styles.css','/icon.svg','/manifest.webmanifest', + '/service-worker.js','/missing-file']: + result = server.request('GET', path) + head = {k.lower(): v for k, v in result[1]} + keys = ['content-type','cache-control','x-content-type-options', + 'x-frame-options','referrer-policy','permissions-policy','content-security-policy'] + observed[path] = (result[0], {k: head.get(k) for k in keys}, result[2]) + assert server.request('HEAD', path)[2] == b'' + for origin in ['https://allowed.example', 'https://denied.example']: + result = server.request('OPTIONS', '/v1/sync', headers={'Origin': origin}) + observed[origin] = (result[0], {k.lower(): v for k, v in result[1] + if k.lower().startswith('access-control') or k.lower() == 'vary'}) + session_result = server.request('POST', '/v1/auth/register', credentials(), + headers={'X-Forwarded-Proto': 'https'}) + session = expect(session_result, 201) + assert session['user']['email'] == 'user@example.com' + cookies = [v for k, v in session_result[1] if k.lower() == 'set-cookie'] + assert len(cookies) == 2 + for cookie in cookies: + assert all(part in cookie for part in ['HttpOnly','Secure','SameSite=Strict','Path=/']) + cookie_header = '; '.join(c.split(';')[0] for c in cookies) + assert expect(server.request('GET', '/v1/me', headers={'Cookie': cookie_header}), 200) == session['user'] + token = session['accessToken'] + assert expect(server.request('GET', '/v1/me', token=token), 200) == session['user'] + observed['invalid bearer'] = expect(server.request('GET', '/v1/me', token='bad'), 401) + observed['duplicate'] = expect(server.request('POST', '/v1/auth/register', credentials()), 409) + expect(server.request('POST', '/v1/auth/login', credentials()), 200) + for payload in [{'cursor': -1}, {'extra': 1}, {'changes': [change('x', entity='unknown')]}, + {'changes': [change('')]}, {'changes': [dict(entity='category', id='x')]}, + {'changes': [change('x', payload=None)]}]: + result = server.request('POST', '/v1/sync', payload, token) + data = result[2] + if isinstance(data, dict): + data.pop('serverTime', None) + observed[f'invalid sync {payload}'] = (result[0], data) + observed['malformed'] = expect(server.request('POST', '/v1/sync', token=token, raw=b'{'), 400) + first = sync(server, token, [change('one'), change('cat', entity='category'), change('budget', entity='budget')]) + first.pop('serverTime') + observed['first'] = first + repeated = sync(server, token, [change('one'), change('one', '2024-01-01T00:00:00Z')], cursor=first['cursor']) + repeated.pop('serverTime') + observed['idempotent'] = repeated + assert repeated['changes'] == [] and repeated['cursor'] == first['cursor'] + deleted = sync(server, token, [change('one', '2025-01-02T00:00:00Z', deleted=True)], cursor=first['cursor']) + deleted.pop('serverTime') + observed['deleted'] = deleted + assert deleted['changes'][0]['deleted'] and 'payload' not in deleted['changes'][0] + pushed = sync(server, token, [change(f'page-{n}') for n in range(505)], cursor=deleted['cursor'], pull=False) + assert pushed['cursor'] == deleted['cursor'] and pushed['changes'] == [] + page = sync(server, token, cursor=deleted['cursor']) + assert len(page['changes']) == 500 and page['hasMore'] + tail = sync(server, token, cursor=page['cursor']) + assert len(tail['changes']) == 5 and not tail['hasMore'] + observed['pagination'] = (page['cursor'], tail['cursor']) + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda n: sync(server, token, [change(f'parallel-{n}')], pull=False), range(16))) + assert all(result['changes'] == [] for result in results) + parallel = sync(server, token, cursor=tail['cursor']) + assert len(parallel['changes']) == 16 + assert len({x['sequence'] for x in parallel['changes']}) == 16 + other = expect(server.request('POST', '/v1/auth/register', credentials('other@example.com')), 201) + assert sync(server, other['accessToken'])['changes'] == [] + refreshed = expect(server.request('POST', '/v1/auth/refresh', {}, headers={'Cookie': cookie_header}), 200) + assert refreshed['refreshToken'] != session['refreshToken'] + observed['refresh replay'] = expect(server.request('POST', '/v1/auth/refresh', {'refreshToken': session['refreshToken']}), 401) + logged_out = server.request('POST', '/v1/auth/logout', {'refreshToken': refreshed['refreshToken']}) + expect(logged_out, 204) + assert len([v for k,v in logged_out[1] if k.lower() == 'set-cookie' and 'Max-Age=0' in v]) == 2 + observed['logout refresh'] = expect(server.request('POST', '/v1/auth/refresh', {'refreshToken': refreshed['refreshToken']}), 401) + # Fresh limiter state permits exactly 30 authentication attempts, including invalid JSON. + with running(binary, database) as server: + statuses = [server.request('POST', '/v1/auth/login', raw=b'{')[0] for _ in range(31)] + assert statuses == [400] * 30 + [429], statuses + result = server.request('POST', '/v1/auth/login', raw=b'{') + assert {k.lower():v for k,v in result[1]}.get('retry-after') == '60' + observed['rate limit'] = result[2] + with running(binary, database, BALANCE_ALLOW_REGISTRATION='false', BALANCE_ACCESS_TTL='1s') as server: + observed['registration disabled'] = expect(server.request('POST', '/v1/auth/register', credentials()), 403) + session = expect(server.request('POST', '/v1/auth/login', credentials()), 200) + time.sleep(1.15) + observed['expired access'] = expect(server.request('GET', '/v1/me', token=session['accessToken']), 401) + return observed + + +def takeover(first, second, database): + with running(first, database) as server: + session = expect(server.request('POST', '/v1/auth/register', credentials()), 201) + original = sync(server, session['accessToken'], [change('persisted')]) + with running(second, database) as server: + assert expect(server.request('GET', '/v1/me', token=session['accessToken']), 200) == session['user'] + assert expect(server.request('POST', '/v1/auth/login', credentials()), 200)['user'] == session['user'] + assert sync(server, session['accessToken'])['changes'] == original['changes'] + next_session = expect(server.request('POST', '/v1/auth/refresh', {'refreshToken': session['refreshToken']}), 200) + updated = sync(server, next_session['accessToken'], [change('persisted','2025-01-02T00:00:00Z',deleted=True)], cursor=original['cursor']) + assert len(updated['changes']) == 1 and updated['changes'][0]['version'] == 2 + with running(first, database) as server: + assert expect(server.request('GET', '/v1/me', token=next_session['accessToken']), 200) == session['user'] + expect(server.request('POST', '/v1/auth/refresh', {'refreshToken': session['refreshToken']}), 401) + expect(server.request('POST', '/v1/auth/refresh', {'refreshToken': next_session['refreshToken']}), 200) + restored = sync(server, session['accessToken'], cursor=original['cursor']) + assert restored['changes'] == updated['changes'] and restored['cursor'] == updated['cursor'] + expect(server.request('POST', '/v1/auth/login', credentials()), 200) + + +def edge_scenarios(binary, database): + """Exercise decoder, routing, validation, range and session boundary behavior.""" + observed = {} + def record(name, result, headers=()): + selected = {k.lower(): v for k, v in result[1] if k.lower() in headers} + data = result[2] + if isinstance(data, dict): + data = {k: v for k, v in data.items() if k != 'serverTime'} + observed[name] = (result[0], selected, data) + + with running(binary, database) as server: + session = expect(server.request('POST', '/v1/auth/register', credentials()), 201) + token = session['accessToken'] + for method in ['POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']: + for path in ['/health', '/v1/me', '/v1/sync', '/app.js', '/v1/auth/login']: + record((method, path), server.request(method, path), ('allow', 'content-type')) + for path in ['/index.html', '/a/../app.js', '//app.js', '/app.js/', + '/v1', '/v1/', '/v1//me', '/v1/../app.js', '/%61pp.js', '/%2e%2e/app.js']: + record(('path', path), server.request('GET', path), ('location', 'content-type', 'cache-control')) + for range_value in ['bytes=0-9', 'bytes=-10', 'bytes=10-', 'bytes=99999999-', + 'bytes=bad', 'items=0-1', 'bytes=10-1', 'bytes=0-0,2-2']: + result = server.request('GET', '/icon.svg', headers={'Range': range_value}) + if result[0] == 206 and 'multipart/' in {k.lower():v for k,v in result[1]}.get('content-type', ''): + # Multipart boundaries are randomly generated in the reference. + assert b'Content-Range: bytes 0-0/' in result[2] + assert b'Content-Range: bytes 2-2/' in result[2] + observed[('range', range_value)] = result[0] + else: + record(('range', range_value), result, ('content-range', 'accept-ranges', 'content-type')) + raw_sync = [b'null', b'{} trailing', b'{"CURSOR":null,"CHANGES":null}', + b'{"cursor":0,"cursor":null}', b'{"cursor":1.0}', b'[]', + b'{"changes":[null]}', b'{"deviceId":false}', b'{"pull":0}', + b'{"changes":[],"changes":null}', b'{"cursor":9223372036854775808}'] + for n, raw in enumerate(raw_sync): + record(('raw sync', n), server.request('POST', '/v1/sync', token=token, raw=raw)) + cases = [change('x', entity='Category'), change('x'*129), + change('x', payload=None), change('x', payload=[1,2,3]), + change('x', stamp='0001-01-01T00:00:00Z'), + change('x', stamp='2025-01-01t00:00:00z'), + change('x', stamp='2025-01-01T0:00:00Z'), + change('x', stamp='2025-01-01T00:00:60Z'), + change('x', stamp='2025-01-01T00:00:00,123456789123Z'), + change('x', stamp='2025-01-01T00:00:00+24:00'), + change('x', stamp='2025-01-01T00:00:00+00:60'), + change('x', stamp='2025-01-01T00:00:00.123456789123Z'), + change('x', stamp='9999-01-01T00:00:00Z'), + change('x', payload='x'*(256*1024))] + for n, value in enumerate(cases): + record(('sync validation', n), server.request('POST', '/v1/sync', {'changes':[value]}, token)) + for n, raw in enumerate([b'null', b'{} tail', b'{"refreshToken":null}', b'{"REFRESHTOKEN":"bad"}', b'{"refreshToken":1}', b'{"extra":1}']): + record(('refresh JSON', n), server.request('POST', '/v1/auth/refresh', raw=raw)) + record(('logout JSON', n), server.request('POST', '/v1/auth/logout', raw=raw)) + for auth in ['', 'Basic bad', 'bearer bad', 'Bearer ', 'Bearer bad']: + record(('bearer', auth), server.request('GET', '/v1/me', headers={'Authorization':auth})) + # Verify signed JWTs with claims from Go's permissive JSON decoder. + for n, claims in enumerate([ + {'sub':'u','email':'e','iss':'balance-server','exp':4102444800}, + {'SUB':'u','EMAIL':'e','ISS':'balance-server','EXP':4102444800}, + {'sub':'u','email':None,'iss':'balance-server','exp':4102444800}, + {'sub':'u','iss':'balance-server','exp':4102444800,'unknown':123}, + {'sub':'u','iss':'wrong','exp':4102444800}, + {'sub':'u','iss':'balance-server','exp':1}, + {'sub':'','iss':'balance-server','exp':4102444800}, + ]): + encode = lambda data: base64.urlsafe_b64encode(data).rstrip(b'=') + unsigned = b'ignored-header.' + encode(json.dumps(claims).encode()) + signed = (unsigned+b'.'+encode(hmac.new(SECRET.encode(), unsigned, hashlib.sha256).digest())).decode() + record(('signed claims', n), server.request('GET', '/v1/me', token=signed)) + # Only one simultaneous rotation of a given refresh session may succeed. + fresh = expect(server.request('POST','/v1/auth/login',credentials()),200) + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + statuses = sorted(pool.map(lambda _: server.request('POST','/v1/auth/refresh',{'refreshToken':fresh['refreshToken']})[0], range(4))) + assert statuses == [200,401,401,401], statuses + with running(binary, database) as server: + for n, raw in enumerate([b'null', b'{"email":null,"password":null}', + b'{"EMAIL":"bad","PASSWORD":"abcdefgh"}', + b'{"email":"bad","email":null}', b'{"email":false}', + b'{"email":"bad"} trailing', b'{"email":"bad","unknown":1}']): + for endpoint in ['register','login']: + record((endpoint, n), server.request('POST', '/v1/auth/'+endpoint, raw=raw)) + for n, value in enumerate([credentials('a@b'),dict(email='utf8@example.com',password='я'*4), + dict(email='long@example.com',password='x'*1025)]): + result = server.request('POST','/v1/auth/register',value) + if result[0] == 201: + assert expect(server.request('POST','/v1/auth/login',value),200)['user'] == result[2]['user'] + observed[('password boundary',n)] = result[0] + else: + record(('password boundary',n), result) + with running(binary,database,BALANCE_REFRESH_TTL='1ms') as server: + session = expect(server.request('POST','/v1/auth/login',credentials()),200) + time.sleep(.05) + record('expired refresh',server.request('POST','/v1/auth/refresh',{'refreshToken':session['refreshToken']})) + return observed + + +def raw_json_scenarios(binary, database): + """RawMessage must retain numbers, duplicate keys and unusual Unicode bytes.""" + observed = {} + with running(binary, database) as server: + session = expect(server.request('POST','/v1/auth/register',credentials()),201) + token = session['accessToken'] + cursor = 0 + for n, payload in enumerate([ + b'{ "duplicate":1, "duplicate":2, "n":123456789012345678901234567890, "big":1e999 }', + br'{"lone":"\ud800","low":"\udfff","pair":"\ud83d\ude00"}', + b'{"invalid":"\xff\xc0\xed\xa0\x80"}', + b'{"html":"<>&","separator":"\xe2\x80\xa8\xe2\x80\xa9"}', + br'{"nested":[{"payload":{"id":"inner"}},"quote\"end",null,true]}', + ]): + raw = (b'{"cursor":'+str(cursor).encode()+b',"changes":[{"entity":"transaction","id":"wire-'+str(n).encode()+ + b'","updatedAt":"2025-01-01T00:00:00Z","payload":'+payload+b'}]}') + result = server.request('POST','/v1/sync',token=token,raw=raw,decode_json=False) + assert result[0] == 200, result + data = result[2] + # Latin-1 preserves byte indices while Python locates the complete JSON value. + start = data.index(b'"payload":') + len(b'"payload":') + _, end = json.JSONDecoder().raw_decode(data[start:].decode('latin-1')) + observed[('wire payload',n)] = data[start:start+end] + decoded = json.loads(data.decode('utf-8','replace')) + cursor = decoded['cursor'] + assert len(decoded['changes']) == 1 + for n, raw in enumerate([br'{"email":"\ud800","password":"abcdefgh"}', + b'{"email":"\xff@example.com","password":"abcdefgh"}']): + # Login has deterministic invalid credentials and exercises scalar normalization. + result = server.request('POST','/v1/auth/login',raw=raw) + observed[('unicode credentials',n)] = (result[0],result[2]) + for n, raw_id in enumerate([br'"\ud800"',b'"\xff"']): + raw = (b'{"cursor":'+str(cursor).encode()+b',"changes":[{"entity":"category","id":'+raw_id+ + b',"updatedAt":"2025-01-02T00:00:00Z","payload":{}}]}') + result = server.request('POST','/v1/sync',token=token,raw=raw) + assert result[0] == 200, result + data = result[2] + data.pop('serverTime') + observed[('unicode id',n)] = data + cursor = data['cursor'] + with sqlite3.connect(database) as db: + db.text_factory = bytes + observed['raw database payloads'] = db.execute('SELECT record_id,payload FROM records ORDER BY record_id').fetchall() + return observed + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--go-binary', type=Path) + parser.add_argument('--rust-binary', type=Path, default=ROOT / 'target/debug/balance-server') + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='balance-compatibility-') as directory: + directory = Path(directory) + go = args.go_binary.resolve() if args.go_binary else directory / 'go-server' + if args.go_binary is None: + subprocess.run(['go','build','-o',str(go),'./cmd/balance-server'], cwd=ROOT, check=True) + rust = args.rust_binary.resolve() + assert rust.is_file(), f'Build Rust first: cargo build --locked (missing {rust})' + print('Running Go reference scenarios...', flush=True) + expected = scenario(go, directory / 'go.db') + print('Running Rust scenarios...', flush=True) + actual = scenario(rust, directory / 'rust.db') + for key in expected: + assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' + print('Comparing HTTP/JSON edge cases...', flush=True) + expected = edge_scenarios(go, directory / 'go-edge.db') + actual = edge_scenarios(rust, directory / 'rust-edge.db') + for key in expected: + assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' + print('Comparing raw JSON payload and Unicode preservation...', flush=True) + expected = raw_json_scenarios(go, directory / 'go-raw.db') + actual = raw_json_scenarios(rust, directory / 'rust-raw.db') + for key in expected: + assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}' + for first, second, name in [(go,rust,'go-rust'),(rust,go,'rust-go')]: + print(f'Checking {name} SQLite takeover and rollback...', flush=True) + takeover(first, second, directory / f'{name}.db') + print('PASS: HTTP differential scenarios and bilateral SQLite takeover/rollback') + + +if __name__ == '__main__': + main()