commit 65de1782c9a7e80a52b6e7a408a464cf64e74b64 Author: wt Date: Thu Oct 29 18:43:28 2026 +0700 initial commit 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()