initial commit

This commit is contained in:
wt committed 2026-10-29 18:43:28 +07:00
commit 65de1782c9
43 files changed
+9164

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
.git
.env
data
*.db
*.db-shm
*.db-wal
balance-server
target
__pycache__
+6
View File
@@ -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=
+10
View File
@@ -0,0 +1,10 @@
.env
data/
dist/
*.db
*.db-shm
*.db-wal
balance-server
balance-server.exe
target/
__pycache__/
+137
View File
@@ -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 ./...` проходит.
- [ ] Документация обновлена.
Generated
+1079
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -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
+18
View File
@@ -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"]
+143
View File
@@ -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 <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`.
+19
View File
@@ -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:
+151
View File
@@ -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 <access-token>
```
## 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 текущей реализации имеют приоритет над этой общей рекомендацией.
+119
View File
@@ -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 совпадал с клиентской логикой.
+119
View File
@@ -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 без периода совместимости.
+85
View File
@@ -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.
+120
View File
@@ -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=<secret> target/release/balance-server`, порт 8080. Docker Compose публикует порт 8123 и сохраняет прежний volume `/data`. Процедура миграции Go → Rust и rollback: [DEPLOYMENT.md](DEPLOYMENT.md).
+62
View File
@@ -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.
Любое изменение протокола требует проверки обеих реализаций.
+79
View File
@@ -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.
+138
View File
@@ -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` — идентификатор устройства, а не механизм авторизации.
+21
View File
@@ -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
)
+51
View File
@@ -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=
+61
View File
@@ -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
}
+90
View File
@@ -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) }
+93
View File
@@ -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
}
+80
View File
@@ -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
}
+245
View File
@@ -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
}
+185
View File
@@ -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
}
+204
View File
@@ -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)
}
}
+169
View File
@@ -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(&currentUpdated, &currentVersion)
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()
}
+34
View File
@@ -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)
}
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title">
<title id="title">Balance</title>
<defs>
<linearGradient id="bg" x1="70" y1="36" x2="446" y2="476" gradientUnits="userSpaceOnUse">
<stop stop-color="#7777ef"/>
<stop offset="1" stop-color="#4242b8"/>
</linearGradient>
<linearGradient id="shine" x1="144" y1="122" x2="366" y2="386" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff"/>
<stop offset="1" stop-color="#dedfff"/>
</linearGradient>
</defs>
<rect x="28" y="28" width="456" height="456" rx="128" fill="url(#bg)"/>
<path d="M141 173c0-25 20-45 45-45h140c25 0 45 20 45 45v18H186c-25 0-45 20-45 45v-63Z" fill="#fff" opacity=".5"/>
<path d="M116 222c0-28 23-51 51-51h179c28 0 51 23 51 51v126c0 28-23 51-51 51H167c-28 0-51-23-51-51V222Z" fill="url(#shine)"/>
<path d="M302 258h95v86h-95c-24 0-43-19-43-43s19-43 43-43Z" fill="#5b5bd6"/>
<circle cx="306" cy="301" r="13" fill="#fff"/>
<path d="M184 250h44M184 289h44M184 328h44" stroke="#5b5bd6" stroke-width="18" stroke-linecap="round" opacity=".72"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#5b5bd6">
<meta name="description" content="Balance — личные финансы на вашем сервере">
<title>Balance</title>
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="toast-region" class="toast-region" aria-live="polite" aria-atomic="true"></div>
<main id="auth-view" class="auth-layout">
<section class="auth-card" aria-labelledby="auth-title">
<div class="brand brand-auth">
<img src="/icon.svg" alt="" width="58" height="58">
<div>
<span class="brand-name">Balance</span>
<span class="brand-caption">Ваши финансы. Ваш сервер.</span>
</div>
</div>
<div class="auth-copy">
<p class="eyebrow">SELF-HOSTED</p>
<h1 id="auth-title">Финансы под вашим контролем</h1>
<p>Войдите в аккаунт этого сервера. Операции синхронизируются с iPhone, Apple Watch, Mac и Android.</p>
</div>
<div class="segmented auth-switch" role="tablist" aria-label="Вход или регистрация">
<button type="button" class="is-active" data-auth-mode="login" role="tab">Войти</button>
<button type="button" data-auth-mode="register" role="tab">Регистрация</button>
</div>
<form id="auth-form" class="stack" autocomplete="on">
<label class="field">
<span>Email</span>
<input id="auth-email" name="email" type="email" inputmode="email" autocomplete="email" required maxlength="254" placeholder="name@example.com">
</label>
<label class="field">
<span>Пароль</span>
<input id="auth-password" name="password" type="password" autocomplete="current-password" required minlength="8" maxlength="1024" placeholder="Минимум 8 символов">
</label>
<p id="auth-error" class="form-error" role="alert" hidden></p>
<button id="auth-submit" class="button button-primary button-large" type="submit">Войти</button>
</form>
<p class="security-note"><span aria-hidden="true">🔒</span> Сессия хранится в защищённых HttpOnly-cookie и недоступна JavaScript.</p>
</section>
</main>
<div id="app-view" class="app-shell" hidden>
<aside class="sidebar">
<div class="brand sidebar-brand">
<img src="/icon.svg" alt="" width="42" height="42">
<div>
<span class="brand-name">Balance</span>
<span class="brand-caption">Личные финансы</span>
</div>
</div>
<nav class="side-nav" aria-label="Основная навигация">
<button type="button" data-route="dashboard"><span>⌂</span> Обзор</button>
<button type="button" data-route="transactions"><span>↕</span> Операции</button>
<button type="button" data-route="analytics"><span>◫</span> Аналитика</button>
<button type="button" data-route="budgets"><span>◎</span> Бюджеты</button>
<button type="button" data-route="categories"><span>◆</span> Категории</button>
<button type="button" data-route="settings"><span>⚙</span> Настройки</button>
</nav>
<div class="sidebar-footer">
<div id="sync-pill" class="sync-pill"><span></span><span>Синхронизировано</span></div>
<div class="account-mini">
<div class="avatar" id="account-avatar">Б</div>
<div>
<strong id="account-email">—</strong>
<span>Личный аккаунт</span>
</div>
</div>
</div>
</aside>
<main class="workspace">
<header class="mobile-header">
<div class="brand">
<img src="/icon.svg" alt="" width="36" height="36">
<span class="brand-name">Balance</span>
</div>
<button type="button" class="icon-button" data-action="sync" aria-label="Синхронизировать">↻</button>
</header>
<div id="content" class="content" tabindex="-1"></div>
</main>
<nav class="bottom-nav" aria-label="Мобильная навигация">
<button type="button" data-route="dashboard"><span>⌂</span><small>Обзор</small></button>
<button type="button" data-route="transactions"><span>↕</span><small>Операции</small></button>
<button type="button" data-route="analytics"><span>◫</span><small>Аналитика</small></button>
<button type="button" data-route="budgets"><span>◎</span><small>Бюджеты</small></button>
<button type="button" data-route="settings"><span>⚙</span><small>Ещё</small></button>
</nav>
</div>
<dialog id="editor-dialog" class="modal">
<div id="dialog-content"></div>
</dialog>
<noscript>Для работы Balance необходимо включить JavaScript.</noscript>
<script src="/app.js" defer></script>
</body>
</html>
+13
View File
@@ -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" }
]
}
+22
View File
@@ -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("/"))));
});
+487
View File
@@ -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; }
}
+432
View File
@@ -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<Vec<u8>, 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<String, String> {
Ok(URL_SAFE_NO_PAD.encode(random_bytes(count)?))
}
pub fn identifier() -> Result<String, String> {
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<Vec<u8>, 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<u8> = value
.bytes()
.filter(|byte| !matches!(byte, b'\r' | b'\n'))
.collect();
engine.decode(cleaned)
}
pub fn hash_password(password: &str) -> Result<String, String> {
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::<Sha256>::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::<Sha256>::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<u32> {
*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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<E: serde::de::Error>(self) -> Result<Claims, E> {
Ok(Claims::default())
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Claims, M::Error> {
let mut claims = Claims::default();
while let Some(key) = map.next_key::<String>()? {
// 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::<Option<String>>()? {
claims.subject = value;
}
}
"email" => {
if let Some(value) = map.next_value::<Option<String>>()? {
claims.email = value;
}
}
"iss" => {
if let Some(value) = map.next_value::<Option<String>>()? {
claims.issuer = value;
}
}
"iat" => {
if let Some(value) = map.next_value::<Option<i64>>()? {
claims.issued = value;
}
}
"exp" => {
if let Some(value) = map.next_value::<Option<i64>>()? {
claims.expires = value;
}
}
"jti" => {
if let Some(value) = map.next_value::<Option<String>>()? {
claims.id = value;
}
}
_ => {
map.next_value::<serde::de::IgnoredAny>()?;
}
}
}
Ok(claims)
}
}
deserializer.deserialize_any(ClaimsVisitor)
}
}
#[derive(Clone)]
pub struct TokenManager {
secret: Vec<u8>,
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<Utc>), 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<Claims, String> {
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::<Sha256>::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::<Sha256>::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::<Sha256>::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::<Claims>(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());
}
}
+160
View File
@@ -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<String>,
}
impl Config {
pub fn load() -> Result<Self, String> {
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<TimeDelta, String> {
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<TimeDelta> {
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::<i128>().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}");
}
}
}
+185
View File
@@ -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<Connection, Box<dyn Error + Send + Sync>> {
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();
}
}
+326
View File
@@ -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<u8> {
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<u16> {
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<u8>, Vec<Vec<u8>>), ()> {
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::<Box<serde_json::value::RawValue>>(&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<usize, ()> {
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<usize, ()> {
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<u8> {
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::<serde_json::Value>(&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<String> = 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::<serde_json::Value>(&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}"#
);
}
}
+49
View File
@@ -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<dyn std::error::Error + Send + Sync>> {
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(())
}
+550
View File
@@ -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<Instant>,
write: Option<Instant>,
idle: bool,
response_done: bool,
}
struct DeadlineIo {
stream: TcpStream,
shared: Arc<Mutex<Deadlines>>,
timeouts: Timeouts,
read_timer: Pin<Box<Sleep>>,
write_timer: Pin<Box<Sleep>>,
}
fn expired(timer: &mut Pin<Box<Sleep>>, deadline: Option<Instant>, 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<io::Result<()>> {
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<io::Result<usize>> {
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<io::Result<()>> {
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<io::Result<()>> {
Pin::new(&mut self.stream).poll_shutdown(cx)
}
}
struct ResponseBody {
inner: Body,
shared: Arc<Mutex<Deadlines>>,
}
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<Option<Result<Frame<Self::Data>, 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<bool>,
) {
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<hyper::body::Incoming>| {
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<TcpListener> {
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<Output = ()>,
) -> 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<io::Result<()>>,
) {
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();
}
}
+1015
View File
File diff suppressed because it is too large. Load diff
+779
View File
@@ -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<Change>,
pull: Option<bool>,
}
#[derive(Default)]
struct Change {
entity: String,
id: String,
deleted: bool,
updated_at: Option<DateTime<Utc>>,
payload: Option<Vec<u8>>,
}
// 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: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
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<E: de::Error>(self) -> Result<Request, E> {
Ok(Request::default())
}
fn visit_map<M: MapAccess<'de>>(self, mut m: M) -> Result<Request, M::Error> {
let mut r = Request::default();
while let Some(key) = m.next_key::<String>()? {
match fold_key(&key).as_str() {
"cursor" => {
if let Some(v) = m.next_value::<Option<i64>>()? {
r.cursor = v;
}
}
"deviceid" => {
let _: Option<String> = 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<Change>);
impl<'de> DeserializeSeed<'de> for ChangesSeed<'_> {
type Value = ();
fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<(), D::Error> {
struct ChangesVisitor<'a>(&'a mut Vec<Change>);
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<E: de::Error>(self) -> Result<(), E> {
self.0.clear();
Ok(())
}
fn visit_seq<S: SeqAccess<'de>>(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<D: Deserializer<'de>>(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<E: de::Error>(self) -> Result<(), E> {
Ok(())
}
fn visit_map<M: MapAccess<'de>>(self, mut m: M) -> Result<(), M::Error> {
let c = self.0;
while let Some(key) = m.next_key::<String>()? {
match fold_key(&key).as_str() {
"entity" => {
if let Some(v) = m.next_value::<Option<String>>()? {
c.entity = v;
}
}
"id" => {
if let Some(v) = m.next_value::<Option<String>>()? {
c.id = v;
}
}
"deleted" => {
if let Some(v) = m.next_value::<Option<bool>>()? {
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::<Box<RawValue>>()?;
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::<Box<RawValue>>()?.get().as_bytes().to_vec());
}
"version" | "sequence" => {
let _: Option<i64> = 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<DateTime<Utc>, &'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<u32, &'static str> {
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<Utc>) -> 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<Vec<u8>, 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(&timestamp(Utc::now())).expect("timestamp JSON")
)
.as_bytes(),
);
Ok(response)
}
#[cfg(test)]
fn synchronize(db: &mut Connection, user_id: &str, body: &[u8]) -> Result<Value, SyncError> {
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<dyn std::error::Error + Send + Sync>;
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<Vec<u8>>, 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<Vec<u8>> = 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::<Box<RawValue>>(&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<Value, SyncError> {
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<Value> = (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<u8> = 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);
}
}
}
+408
View File
@@ -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()