Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32169aed1 | ||
|
|
effe959820 | ||
|
|
7fbe86a943 | ||
|
|
dfb4fe4e1a | ||
|
|
f97cdf0f2e | ||
|
|
10272f3851 | ||
|
|
16dd2a3b3c | ||
|
|
2b34c2e318 |
No files matched your search
@@ -9,10 +9,6 @@ env:
|
|||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
# Pin the toolchain to the declared MSRV so CI matches production.
|
# Pin the toolchain to the declared MSRV so CI matches production.
|
||||||
RUST_TOOLCHAIN: 1.90.0
|
RUST_TOOLCHAIN: 1.90.0
|
||||||
# The differential check compares Rust against net/http behaviour, which
|
|
||||||
# changes between Go releases (e.g. ServeMux redirects 301 -> 307), so the
|
|
||||||
# reference must be built with the same Go the Rust port mirrors.
|
|
||||||
GO_VERSION: "1.27.3"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
@@ -58,39 +54,10 @@ jobs:
|
|||||||
path: target/release/balance-server
|
path: target/release/balance-server
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
go:
|
|
||||||
name: Go (test / vet / build)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: ${{ env.GO_VERSION }}
|
|
||||||
|
|
||||||
- name: Cache Go modules
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/go/pkg/mod
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: go test ./...
|
|
||||||
|
|
||||||
- name: Vet
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: go build ./...
|
|
||||||
|
|
||||||
compatibility:
|
compatibility:
|
||||||
name: Go ↔ Rust differential + SQLite takeover
|
name: HTTP/JSON reference + SQLite persistence
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [rust, go]
|
needs: [rust]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -100,11 +67,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||||
|
|
||||||
- name: Install Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: ${{ env.GO_VERSION }}
|
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -121,10 +83,8 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-
|
${{ runner.os }}-cargo-
|
||||||
|
|
||||||
- name: Build Rust (debug) and Go reference
|
- name: Build Rust (debug)
|
||||||
run: |
|
run: cargo build --locked
|
||||||
cargo build --locked
|
|
||||||
go build -o target/integration/go-server ./cmd/balance-server
|
|
||||||
|
|
||||||
- name: Run differential compatibility scenarios
|
- name: Compare against the recorded Go reference
|
||||||
run: python3 tools/compatibility.py --rust-binary target/debug/balance-server
|
run: python3 tools/compatibility.py --binary target/debug/balance-server
|
||||||
@@ -8,28 +8,15 @@
|
|||||||
|
|
||||||
## Быстрая карта проекта
|
## Быстрая карта проекта
|
||||||
|
|
||||||
Production-сервер написан на Rust 1.90+. Go 1.25.0+ сохранён как
|
Сервер написан на Rust 1.90+. Исходная Go-реализация удалена после v1.0.0
|
||||||
эталон совместимости API/SQLite и вариант отката.
|
(её исходники остались в теге `v1.0.0`), но её поведение остаётся эталоном API:
|
||||||
|
ответы Go записаны в `tools/compatibility_expected.txt`, а исходная SQLite-схема
|
||||||
|
Go — в `tests/fixtures/go_schema_v1.sql`. Оба файла не правь вручную.
|
||||||
|
|
||||||
Основные Rust-модули: `src/main.rs`, `runtime.rs`, `config.rs`,
|
Основные Rust-модули: `src/main.rs`, `runtime.rs`, `config.rs`,
|
||||||
`database.rs`, `auth.rs`, `server.rs`, `sync.rs`, `go_json.rs`.
|
`database.rs`, `auth.rs`, `server.rs`, `sync.rs`, `go_json.rs`,
|
||||||
Веб-клиент из `internal/httpapi/web` встроен в Rust executable.
|
`ledgers.rs`, `attachments.rs` (контракт v2, `docs/CONTRACT_V2.md`).
|
||||||
|
Веб-клиент из `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.
|
Обнаруженные технологии/интеграции: SQLite, Docker.
|
||||||
|
|
||||||
@@ -106,12 +93,13 @@ cargo fmt --check
|
|||||||
cargo test --locked
|
cargo test --locked
|
||||||
cargo clippy --locked --all-targets -- -D warnings
|
cargo clippy --locked --all-targets -- -D warnings
|
||||||
cargo build --locked --release
|
cargo build --locked --release
|
||||||
python3 tools/compatibility.py --rust-binary target/release/balance-server
|
python3 tools/compatibility.py --binary target/release/balance-server
|
||||||
go test ./...
|
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Если `tools/compatibility.py` нашёл расхождение, это изменение видно клиентам.
|
||||||
|
Перезаписывай эталон (`--update`) только для намеренного изменения API и
|
||||||
|
после обновления документации.
|
||||||
|
|
||||||
Если проект использует дополнительные проверки/линтеры, запускай их по Makefile/CI.
|
Если проект использует дополнительные проверки/линтеры, запускай их по Makefile/CI.
|
||||||
|
|
||||||
### 8. Документация
|
### 8. Документация
|
||||||
@@ -130,8 +118,6 @@ go build ./...
|
|||||||
- [ ] Sync остаётся идемпотентным.
|
- [ ] Sync остаётся идемпотентным.
|
||||||
- [ ] DB migration учтена.
|
- [ ] DB migration учтена.
|
||||||
- [ ] Concurrent requests безопасны.
|
- [ ] Concurrent requests безопасны.
|
||||||
- [ ] `go test ./...` проходит.
|
|
||||||
- [ ] Rust tests, fmt, clippy и release build проходят.
|
- [ ] Rust tests, fmt, clippy и release build проходят.
|
||||||
- [ ] Differential HTTP/JSON и Go ↔ Rust SQLite takeover проходят.
|
- [ ] `tools/compatibility.py` совпадает с записанным эталоном Go.
|
||||||
- [ ] `go vet ./...` проходит.
|
|
||||||
- [ ] Документация обновлена.
|
- [ ] Документация обновлена.
|
||||||
Generated
+1
-1
@@ -89,7 +89,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "balance-server"
|
name = "balance-server"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "balance-server"
|
name = "balance-server"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ RUN set -eu; \
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY internal/httpapi/web ./internal/httpapi/web
|
COPY web ./web
|
||||||
RUN . /cross.env \
|
RUN . /cross.env \
|
||||||
&& cargo build --locked --release --target "$RUST_TARGET" \
|
&& cargo build --locked --release --target "$RUST_TARGET" \
|
||||||
&& cp "target/$RUST_TARGET/release/balance-server" /balance-server
|
&& cp "target/$RUST_TARGET/release/balance-server" /balance-server
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Balance Server
|
# 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.
|
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
|
## Quick start with Docker
|
||||||
@@ -43,8 +45,7 @@ $env:BALANCE_DB_PATH = ".\data\balance.db"
|
|||||||
|
|
||||||
For another platform, build on that platform or install its Rust target and C toolchain
|
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
|
for bundled SQLite. `cargo build --locked --release --target <target>` requires the
|
||||||
matching linker and C compiler. The retained Go implementation is a compatibility
|
matching linker and C compiler.
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ The browser session uses `HttpOnly`, `SameSite=Strict` cookies. Native applicati
|
|||||||
| `BALANCE_ACCESS_TTL` | `15m` | Access-token lifetime |
|
| `BALANCE_ACCESS_TTL` | `15m` | Access-token lifetime |
|
||||||
| `BALANCE_REFRESH_TTL` | `720h` | Refresh-token lifetime |
|
| `BALANCE_REFRESH_TTL` | `720h` | Refresh-token lifetime |
|
||||||
| `BALANCE_CORS_ORIGINS` | empty | Comma-separated browser origins; native apps do not need CORS |
|
| `BALANCE_CORS_ORIGINS` | empty | Comma-separated browser origins; native apps do not need CORS |
|
||||||
|
| `BALANCE_ATTACHMENTS_DIR` | `attachments` next to `BALANCE_DB_PATH` | Directory for attachment files (receipt photos); Docker: `/data/attachments` |
|
||||||
|
|
||||||
## HTTPS
|
## HTTPS
|
||||||
|
|
||||||
@@ -98,23 +100,33 @@ Then enter `https://balance.example.com` in each application's Settings → Serv
|
|||||||
| `POST` | `/v1/auth/refresh` | Rotate session tokens |
|
| `POST` | `/v1/auth/refresh` | Rotate session tokens |
|
||||||
| `POST` | `/v1/auth/logout` | Revoke refresh token |
|
| `POST` | `/v1/auth/logout` | Revoke refresh token |
|
||||||
| `GET` | `/v1/me` | Current account |
|
| `GET` | `/v1/me` | Current account |
|
||||||
| `POST` | `/v1/sync` | Push and pull changes |
|
| `POST` | `/v1/sync` | Push and pull changes (`X-Balance-Ledger` selects a shared ledger) |
|
||||||
|
| `GET` | `/v1/capabilities` | Contract version and features (no authorization) |
|
||||||
|
| `GET`, `POST` | `/v1/ledgers` | List / create shared ledgers |
|
||||||
|
| `PATCH`, `DELETE` | `/v1/ledgers/{id}` | Rename / delete a ledger (owner) |
|
||||||
|
| `POST` | `/v1/ledgers/{id}/invites` | One-time invite code (owner) |
|
||||||
|
| `POST` | `/v1/ledgers/join` | Join a ledger by code |
|
||||||
|
| `DELETE` | `/v1/ledgers/{id}/members/{userId}` | Remove a member or leave |
|
||||||
|
| `PUT`, `GET` | `/v1/attachments/{sha256}` | Upload / download attachment bytes |
|
||||||
|
|
||||||
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.
|
The server implements ecosystem contract v2 ([docs/CONTRACT_V2.md](docs/CONTRACT_V2.md)).
|
||||||
|
The sync endpoint accepts transactions, categories, budgets, accounts, transfers,
|
||||||
|
recurring operations, goals, debts, exchange rates, settings and attachment metadata. 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
|
## Operational notes
|
||||||
|
|
||||||
- Registration can be disabled after the required accounts have been created.
|
- 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.
|
- 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.
|
- Back up the SQLite database and its `-wal`/`-shm` files together with the attachments directory, or stop the server before copying them.
|
||||||
- Changing `BALANCE_JWT_SECRET` invalidates access tokens. Existing refresh sessions remain valid and will receive new access tokens after refresh.
|
- 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.
|
- 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
|
## Compatibility and migration
|
||||||
|
|
||||||
The Rust server retains the HTTP/JSON API, embedded web client, environment
|
The Rust server replaced the original Go implementation and retains its HTTP/JSON
|
||||||
variables, SQLite schema, Argon2id password hashes, JWT format, refresh sessions
|
API, embedded web client, environment variables, SQLite schema, Argon2id password
|
||||||
and sync cursor semantics of the Go implementation. Docker Compose still maps
|
hashes, JWT format, refresh sessions and sync cursor semantics. The Go sources were
|
||||||
|
removed after v1.0.0; that tag still contains them for reference or rollback. Docker Compose still maps
|
||||||
host port **8123** to container port **8080** and keeps `/data` in `balance-data`.
|
host port **8123** to container port **8080** and keeps `/data` in `balance-data`.
|
||||||
Direct binary execution defaults to port 8080.
|
Direct binary execution defaults to port 8080.
|
||||||
|
|
||||||
@@ -124,8 +136,7 @@ against the same `BALANCE_DB_PATH`; do not run both writers simultaneously.
|
|||||||
Existing accounts, access/refresh tokens and saved device cursors remain usable.
|
Existing accounts, access/refresh tokens and saved device cursors remain usable.
|
||||||
See [deployment and rollback](docs/DEPLOYMENT.md) for the full procedure.
|
See [deployment and rollback](docs/DEPLOYMENT.md) for the full procedure.
|
||||||
|
|
||||||
Verify the implementations locally with Go 1.27.3 (the version CI uses for the
|
Verify the server locally with Rust and Python 3:
|
||||||
differential check), Rust and Python 3:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo test --locked
|
cargo test --locked
|
||||||
@@ -133,12 +144,11 @@ cargo fmt --check
|
|||||||
cargo clippy --locked --all-targets -- -D warnings
|
cargo clippy --locked --all-targets -- -D warnings
|
||||||
cargo build --locked
|
cargo build --locked
|
||||||
python3 tools/compatibility.py
|
python3 tools/compatibility.py
|
||||||
go test ./...
|
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The Python standard-library suite builds the Go reference, starts both binaries
|
The Python standard-library suite starts the server on temporary loopback ports
|
||||||
on temporary loopback ports and databases, compares API responses and assets,
|
and databases, compares its API responses and assets with the responses recorded
|
||||||
and tests SQLite takeover and rollback in both directions. To use release or
|
from the original Go server (`tools/compatibility_expected.txt`), and checks that
|
||||||
prebuilt executables, pass `--rust-binary PATH` and optionally `--go-binary PATH`.
|
SQLite data survives restarts. To test a release or prebuilt executable, pass
|
||||||
|
`--binary PATH`. After an intentional API change, review the difference and
|
||||||
|
re-record the reference with `--update`.
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"balance-server/internal/config"
|
|
||||||
"balance-server/internal/database"
|
|
||||||
"balance-server/internal/httpapi"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("configuration error", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
db, err := database.Open(cfg.DatabasePath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("database error", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
server := &http.Server{
|
|
||||||
Addr: cfg.Address,
|
|
||||||
Handler: httpapi.New(cfg, db, logger),
|
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
|
||||||
ReadTimeout: 15 * time.Second,
|
|
||||||
WriteTimeout: 30 * time.Second,
|
|
||||||
IdleTimeout: 60 * time.Second,
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
logger.Info("Balance server started", "address", cfg.Address, "database", cfg.DatabasePath)
|
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
logger.Error("server stopped", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
stop := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
|
||||||
<-stop
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := server.Shutdown(ctx); err != nil {
|
|
||||||
logger.Error("graceful shutdown failed", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+73
-4
@@ -82,14 +82,21 @@ POST /v1/sync
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Поддерживаемые entity:
|
Поддерживаемые entity (контракт v2, `docs/CONTRACT_V2.md` §1):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
transaction
|
transaction category budget account transfer recurring
|
||||||
category
|
goal debt rate setting attachment
|
||||||
budget
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Незнакомая entity отклоняется `400 invalid_change`; текст сообщения
|
||||||
|
(`entity must be transaction, category, or budget`) сохранён из v1, потому что
|
||||||
|
он записан в эталоне `tools/compatibility_expected.txt`. Payload сервер хранит
|
||||||
|
как есть и не валидирует по сущности.
|
||||||
|
|
||||||
|
Заголовок `X-Balance-Ledger: <id>` выбирает общее пространство (см. ниже). Без
|
||||||
|
заголовка (или с пустым значением) — личное пространство, как в v1.
|
||||||
|
|
||||||
## Push
|
## Push
|
||||||
|
|
||||||
Client отправляет локальные изменения.
|
Client отправляет локальные изменения.
|
||||||
@@ -112,6 +119,68 @@ Server возвращает изменения после этого cursor, в
|
|||||||
|
|
||||||
Если существует pagination, ответ должен содержать понятный признак продолжения и новый cursor/sequence.
|
Если существует pagination, ответ должен содержать понятный признак продолжения и новый cursor/sequence.
|
||||||
|
|
||||||
|
## Контракт v2
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
|
||||||
|
`GET /v1/capabilities` — без авторизации:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"contract":2,
|
||||||
|
"entities":["transaction","category","budget","account","transfer","recurring","goal","debt","rate","setting","attachment"],
|
||||||
|
"attachments":{"maxBytes":10485760,"types":["image/jpeg","image/png","image/heic","application/pdf"]},
|
||||||
|
"ledgers":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Общие пространства (ledgers)
|
||||||
|
|
||||||
|
Все запросы — с `Authorization`. Объект ledger:
|
||||||
|
`{"id","name","role","members":[{"userId","email","role"}]}`, `role` —
|
||||||
|
роль текущего пользователя (`owner` | `member`), владелец идёт первым в `members`.
|
||||||
|
|
||||||
|
| Метод | Путь | Успех | Кто |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/v1/ledgers` | `200 {"ledgers":[…]}` (только общие) | любой |
|
||||||
|
| POST | `/v1/ledgers` `{"name"}` | `201` ledger, `role:"owner"` | любой |
|
||||||
|
| PATCH | `/v1/ledgers/{id}` `{"name"}` | `200` ledger | владелец |
|
||||||
|
| DELETE | `/v1/ledgers/{id}` | `204` | владелец; удаляет записи, курсор, приглашения, участников и файлы вложений |
|
||||||
|
| POST | `/v1/ledgers/{id}/invites` | `201 {"code","expiresAt"}` | владелец |
|
||||||
|
| POST | `/v1/ledgers/join` `{"code"}` | `200` ledger, `role:"member"` | любой |
|
||||||
|
| DELETE | `/v1/ledgers/{id}/members/{userId}` | `204` | владелец — любого участника; участник — себя |
|
||||||
|
|
||||||
|
- Имя: после `trim` 1–100 символов без управляющих → иначе `400 invalid_name`.
|
||||||
|
- Код приглашения: 10 символов из `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, живёт 7 дней,
|
||||||
|
одноразовый. При вводе регистр, пробелы и `-` игнорируются. Повторный join
|
||||||
|
участника возвращает ledger и код не расходует. `/v1/ledgers/join` входит в
|
||||||
|
общий лимит 30 запросов/мин на IP вместе с auth endpoints.
|
||||||
|
- Ошибки: `400 invalid_code` (неверный, использованный или просроченный код),
|
||||||
|
`403 forbidden` (не участник или не владелец), `404 not_found` (нет такого
|
||||||
|
пространства или участника), `409 owner_cannot_leave` (владелец пытается
|
||||||
|
выйти — он может только удалить пространство), `400 invalid_json`.
|
||||||
|
- `X-Balance-Ledger` на `/v1/sync` и `/v1/attachments/*`: не участник → `403
|
||||||
|
forbidden`, несуществующее → `404 not_found`.
|
||||||
|
|
||||||
|
### Вложения
|
||||||
|
|
||||||
|
| Метод | Путь | Ответ |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PUT | `/v1/attachments/{sha256}` | тело — байты; `201` первый раз, `200` повтор; `{"sha256","size","contentType"}` |
|
||||||
|
| GET/HEAD | `/v1/attachments/{sha256}` | `200`, байты, сохранённый `Content-Type`, `Content-Disposition: attachment`; `404 not_found`, если в пространстве нет файла |
|
||||||
|
|
||||||
|
- `{sha256}` — 64 hex-символа в нижнем регистре; SHA-256 тела должен совпасть,
|
||||||
|
иначе `400 hash_mismatch` (так же для неверного формата хеша в PUT).
|
||||||
|
- `Content-Type` (параметры игнорируются) — `image/jpeg`, `image/png`,
|
||||||
|
`image/heic`, `application/pdf`, иначе `415 unsupported_media_type`.
|
||||||
|
- Больше 10 MiB → `413 payload_too_large`. Лимит тела 10 MiB действует только для
|
||||||
|
авторизованного PUT; остальные запросы по-прежнему ограничены 2 MiB.
|
||||||
|
|
||||||
|
### CORS
|
||||||
|
|
||||||
|
Ответы v1 сохраняют заголовки эталона Go. Для маршрутов v2 и для настоящих
|
||||||
|
preflight-запросов (есть `Access-Control-Request-Method`) разрешённые origin
|
||||||
|
получают `Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS`
|
||||||
|
и `Access-Control-Allow-Headers: Authorization, Content-Type, X-Balance-Ledger`.
|
||||||
|
|
||||||
## Ошибки
|
## Ошибки
|
||||||
|
|
||||||
Рекомендуемый контракт:
|
Рекомендуемый контракт:
|
||||||
|
|||||||
@@ -23,16 +23,9 @@ Android ───────────┘
|
|||||||
|
|
||||||
## Исходный код
|
## Исходный код
|
||||||
|
|
||||||
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.
|
Production runtime — один Rust-процесс: `src/main.rs`, `runtime.rs`, `config.rs`, `database.rs`, `auth.rs`, `sync.rs`, `server.rs`, `go_json.rs`, `ledgers.rs` (общие пространства контракта v2), `attachments.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. Статические файлы из `web/` встроены при компиляции. Маршруты контракта v2 (`/v1/capabilities`, `/v1/ledgers*`, `/v1/attachments/*`) разбираются в `server.rs` до маршрутов v1, которые сохраняют поведение эталона Go.
|
||||||
|
|
||||||
Пути сохранённой Go-реализации:
|
Исходная Go-реализация удалена после v1.0.0 (исходники остались в теге `v1.0.0`). Её наблюдаемое поведение зафиксировано в `tools/compatibility_expected.txt`, а SQLite-схема — в `tests/fixtures/go_schema_v1.sql`; Rust сверяется с обоими в CI.
|
||||||
|
|
||||||
```text
|
|
||||||
- internal/auth
|
|
||||||
- internal/config
|
|
||||||
- internal/database
|
|
||||||
- internal/httpapi
|
|
||||||
```
|
|
||||||
|
|
||||||
Конкретные границы package/handler/service/repository нужно сохранять при рефакторинге: перенос кода между слоями не должен менять внешний API.
|
Конкретные границы package/handler/service/repository нужно сохранять при рефакторинге: перенос кода между слоями не должен менять внешний API.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Контракт экосистемы v2
|
||||||
|
|
||||||
|
> Общий документ BalanceServer, BalanceFlutter и BalanceWatch. Одинаковая
|
||||||
|
> копия лежит в каждом репозитории: `docs/ecosystem/CONTRACT_V2.md`
|
||||||
|
> (Flutter), `docs/CONTRACT_V2.md` (сервер, часы). Меняется только во всех
|
||||||
|
> трёх сразу.
|
||||||
|
|
||||||
|
v2 добавляет счета и переводы, регулярные операции, мультивалютность, цели,
|
||||||
|
теги, вложения (фото чеков), общие бюджеты и долги. Всё сделано
|
||||||
|
**добавлением**: формат `/v1/sync`, курсор, `sequence`, `updatedAt`,
|
||||||
|
tombstone и last-write-wins не меняются. Клиент v1 продолжает работать с
|
||||||
|
сервером v2, но не видит новых данных (ограничения — в конце).
|
||||||
|
|
||||||
|
## 0. Общие правила
|
||||||
|
|
||||||
|
1. **Незнакомая сущность** при pull пропускается молча (курсор двигается).
|
||||||
|
2. **Незнакомые поля** payload знакомой сущности клиент v2 **сохраняет** и
|
||||||
|
отправляет обратно без изменений при следующем сохранении записи
|
||||||
|
(forward compatibility). Клиент v1 их теряет — поэтому обновлять нужно все
|
||||||
|
клиенты.
|
||||||
|
3. Все новые поля payload необязательные. Отсутствие поля = значение по
|
||||||
|
умолчанию, указанное ниже.
|
||||||
|
4. Деньги на проводе — числа (`amount`, `limit`, `target`…), как в v1;
|
||||||
|
внутри клиентов — целые сотые. Курс валют — **строка** десятичного числа.
|
||||||
|
5. Даты — ISO 8601 UTC. Интервалы — `[начало, конец)`.
|
||||||
|
6. Коды валют — ISO 4217 в верхнем регистре (`RUB`, `EUR`, `USD`, `SEK`, …).
|
||||||
|
7. Новые записи получают UUID в нижнем регистре (кроме `setting`, где `id` —
|
||||||
|
ключ). Сервер сравнивает `id` как есть, с учётом регистра, поэтому клиент
|
||||||
|
хранит `id` полученной записи в точности как пришёл и отправляет его так же
|
||||||
|
(старые клиенты могли создавать UUID в верхнем регистре).
|
||||||
|
|
||||||
|
### Возможности сервера
|
||||||
|
|
||||||
|
`GET /v1/capabilities` (без авторизации):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contract": 2,
|
||||||
|
"entities": ["transaction","category","budget","account","transfer",
|
||||||
|
"recurring","goal","debt","rate","setting","attachment"],
|
||||||
|
"attachments": {"maxBytes": 10485760,
|
||||||
|
"types": ["image/jpeg","image/png","image/heic","application/pdf"]},
|
||||||
|
"ledgers": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервер v1 отвечает 404 — клиент v2 тогда скрывает вложения и общие бюджеты и
|
||||||
|
не отправляет новые сущности.
|
||||||
|
|
||||||
|
## 1. Сущности
|
||||||
|
|
||||||
|
Белый список сервера: `transaction`, `category`, `budget`, `account`,
|
||||||
|
`transfer`, `recurring`, `goal`, `debt`, `rate`, `setting`, `attachment`.
|
||||||
|
|
||||||
|
### transaction — новые поля
|
||||||
|
|
||||||
|
| Поле | Тип | По умолчанию | Смысл |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `accountId` | string? | нет | счёт; без него — «Без счёта» |
|
||||||
|
| `currency` | string? | валюта счёта, иначе базовая | валюта суммы |
|
||||||
|
| `tags` | [string]? | `[]` | теги, см. §6 |
|
||||||
|
| `recurringId` | string? | нет | создана из регулярной операции |
|
||||||
|
| `occurrence` | string? | нет | дата повторения `YYYY-MM-DD` |
|
||||||
|
|
||||||
|
`kindRawValue` остаётся `income` / `expense`. Перевод — **не** транзакция.
|
||||||
|
|
||||||
|
### account
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"Карта","kindRawValue":"card","icon":"creditcard.fill",
|
||||||
|
"colorName":"blue","currency":"RUB","createdAt":"…","isArchived":false,
|
||||||
|
"sortOrder":0}
|
||||||
|
```
|
||||||
|
|
||||||
|
`kindRawValue`: `cash` | `card` | `deposit` | `other` (незнакомое → `other`).
|
||||||
|
Клиент v2 записывает `currency` явно при создании счёта. Отсутствие поля
|
||||||
|
(старая запись) = текущая базовая валюта.
|
||||||
|
|
||||||
|
### transfer
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"fromAccountId":"…","toAccountId":"…","amount":1000.0,"toAmount":10.5,
|
||||||
|
"date":"…","note":""}
|
||||||
|
```
|
||||||
|
|
||||||
|
`toAmount` нужен, когда валюты счетов различаются; по умолчанию = `amount`.
|
||||||
|
`amount` — в валюте счёта-источника, `toAmount` — в валюте счёта-получателя.
|
||||||
|
|
||||||
|
### recurring
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"kindRawValue":"expense","amount":990.0,"currency":"RUB","accountId":null,
|
||||||
|
"categoryName":"Подписки","categoryIcon":"repeat","categoryEmoji":"",
|
||||||
|
"categoryColorName":"cyan","note":"Связь","tags":[],
|
||||||
|
"frequency":"monthly","interval":1,"startDate":"2026-01-05T09:00:00Z",
|
||||||
|
"endDate":null,"mode":"auto","isPaused":false,"generatedThrough":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
`frequency`: `daily` | `weekly` | `monthly` | `yearly`; `interval` ≥ 1;
|
||||||
|
`mode`: `auto` (создаётся само) | `remind` (напоминание, пользователь
|
||||||
|
подтверждает). `generatedThrough` — дата `YYYY-MM-DD` последнего
|
||||||
|
обработанного повторения.
|
||||||
|
|
||||||
|
### goal
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"Отпуск","target":150000.0,"currency":"RUB","deadline":null,
|
||||||
|
"accountId":null,"saved":20000.0,"icon":"airplane","colorName":"sky",
|
||||||
|
"createdAt":"…","isArchived":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
Прогресс = баланс счёта `accountId`, если он задан, иначе `saved`.
|
||||||
|
|
||||||
|
### debt
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"person":"Иван","direction":"owedToMe","amount":5000.0,"currency":"RUB",
|
||||||
|
"date":"…","dueDate":null,"note":"","repaid":1000.0,"isClosed":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
`direction`: `iOwe` | `owedToMe`. Остаток = `amount − repaid`. Долги **не**
|
||||||
|
входят в баланс и аналитику: деньги, которые реально ушли или пришли,
|
||||||
|
пользователь записывает обычной операцией.
|
||||||
|
|
||||||
|
### rate
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"from":"USD","to":"RUB","rate":"92.35","date":"2026-10-31T00:00:00Z"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`rate` — сколько единиц `to` стоит одна единица `from`, до 6 знаков после
|
||||||
|
точки.
|
||||||
|
|
||||||
|
### setting
|
||||||
|
|
||||||
|
`id` — ключ, payload `{"value": …}`. Сейчас ключ один: `baseCurrency`
|
||||||
|
(строка ISO 4217) — валюта, в которой считаются общие суммы. Если записи нет,
|
||||||
|
базовая валюта — локальная настройка устройства (поведение v1).
|
||||||
|
|
||||||
|
### attachment
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"transactionId":"…","sha256":"<64 hex>","contentType":"image/jpeg",
|
||||||
|
"size":182345,"name":"чек.jpg","createdAt":"…"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Метаданные синхронизируются обычным `/v1/sync`; сами байты — через §8.
|
||||||
|
|
||||||
|
## 2. Валюты и курсы
|
||||||
|
|
||||||
|
- Валюта транзакции: `currency`, иначе валюта её счёта, иначе базовая.
|
||||||
|
- Перевод суммы `x` из `C` в базовую `B`: при `C == B` без изменений; иначе
|
||||||
|
берётся запись `rate` с `from=C,to=B` или обратная `from=B,to=C` (тогда
|
||||||
|
делим). Выбор: самая поздняя `date` не позже даты расчёта (курсы с будущей
|
||||||
|
датой не используются); при равной дате прямая пара важнее обратной; затем
|
||||||
|
более поздний `updatedAt`.
|
||||||
|
- Результат округляется до сотых половиной вверх (от нуля). Считать через
|
||||||
|
целые сотые и десятичную строку курса, не через `double`-накопление.
|
||||||
|
- Нет курса → сумма **не входит** в общие итоги, клиент показывает
|
||||||
|
предупреждение «нет курса C→B». Внутри счёта суммы складываются в валюте
|
||||||
|
счёта без конвертации.
|
||||||
|
- Курс для периода — последний на дату расчёта (не на дату операции).
|
||||||
|
|
||||||
|
## 3. Баланс (расширяет финансовые правила v1)
|
||||||
|
|
||||||
|
- Баланс счёта (в его валюте) = доходы − расходы с этим `accountId`
|
||||||
|
(включая корректировки) + `toAmount` входящих переводов − `amount`
|
||||||
|
исходящих.
|
||||||
|
- «Без счёта» = транзакции без `accountId` (в своих валютах).
|
||||||
|
- Общий баланс = сумма всех транзакций в базовой валюте + переводы
|
||||||
|
(входящая часть − исходящая, каждая в базовой валюте). При одной валюте
|
||||||
|
переводы дают ноль, и общий баланс совпадает с v1.
|
||||||
|
- Переводы не входят в аналитику и бюджеты. Корректировки — как в v1.
|
||||||
|
- Аналитика и бюджеты складывают суммы в базовой валюте.
|
||||||
|
- Архивный счёт скрыт из выбора, но его баланс входит в общий.
|
||||||
|
- Транзакции и переводы, ссылающиеся на удалённый счёт, остаются; клиент
|
||||||
|
показывает их как «Удалённый счёт».
|
||||||
|
|
||||||
|
## 4. Регулярные операции
|
||||||
|
|
||||||
|
- Повторения считаются в календаре **UTC** от даты `startDate` (её UTC-дата
|
||||||
|
и время суток): `daily` — каждые `interval` дней, `weekly` — каждые
|
||||||
|
`7·interval` дней, `monthly` — каждые `interval` месяцев в тот же день
|
||||||
|
(если дня нет — последний день месяца), `yearly` — то же с годами
|
||||||
|
(29 февраля → 28 февраля). `endDate` сравнивается по UTC-дате и включается:
|
||||||
|
повторение в день `endDate` создаётся, после — нет.
|
||||||
|
- `occurrence` = UTC-дата `YYYY-MM-DD`; дата транзакции = эта дата + время
|
||||||
|
суток `startDate`.
|
||||||
|
- ID транзакции **детерминирован**: UUID v5 от пространства имён
|
||||||
|
`6f1c3f5e-8a8d-4c3a-9d2b-4b1f0e7a2c11` и строки
|
||||||
|
`recurring:<recurringId>:<occurrence>` (нижний регистр). Два устройства
|
||||||
|
создают одну и ту же запись — дублей нет.
|
||||||
|
- `auto`: клиент создаёт все повторения в `(generatedThrough, сегодня UTC]`
|
||||||
|
(с `startDate`, если `generatedThrough` пуст) и ставит
|
||||||
|
`generatedThrough` = последнее обработанное. Удалённое пользователем
|
||||||
|
повторение не воскрешается, т.к. `generatedThrough` уже позади. Не более
|
||||||
|
366 повторений за раз.
|
||||||
|
- `remind`: клиент показывает повторения из того же окна как «ожидают»;
|
||||||
|
«Записать» создаёт транзакцию с тем же детерминированным ID и двигает
|
||||||
|
`generatedThrough` только через подряд обработанные повторения (если
|
||||||
|
записать более позднее раньше, более раннее остаётся ожидать).
|
||||||
|
«Пропустить» двигает `generatedThrough` до этого повторения включительно —
|
||||||
|
более ранние ожидающие тоже считаются пропущенными.
|
||||||
|
- `isPaused` — ничего не создаётся и не напоминается.
|
||||||
|
- Часы регулярные операции не создают (их создают телефон и десктоп).
|
||||||
|
|
||||||
|
## 5. Цели, долги — см. §1. Цели и долги не меняют баланс.
|
||||||
|
|
||||||
|
## 6. Теги
|
||||||
|
|
||||||
|
Строки после `trim`, 1–32 символа, без учёта регистра уникальны в операции,
|
||||||
|
не больше 10 на операцию. Порядок сохраняется. Аналитика и бюджеты тегами не
|
||||||
|
фильтруются; список операций — фильтруется.
|
||||||
|
|
||||||
|
## 7. Общие бюджеты (ledgers)
|
||||||
|
|
||||||
|
Пространство данных (ledger) — отдельный набор записей со своим курсором.
|
||||||
|
У каждого пользователя есть **личное** пространство (как в v1). Общее
|
||||||
|
пространство создаёт владелец и приглашает участников по коду.
|
||||||
|
|
||||||
|
| Метод | Путь | Тело / ответ |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/v1/ledgers` | `{"ledgers":[{"id","name","role","members":[{"userId","email","role"}]}]}` — только общие |
|
||||||
|
| POST | `/v1/ledgers` | `{"name"}` → ledger, `role:"owner"` |
|
||||||
|
| PATCH | `/v1/ledgers/{id}` | `{"name"}` (владелец) |
|
||||||
|
| DELETE | `/v1/ledgers/{id}` | владелец: удалить пространство со всеми записями и вложениями |
|
||||||
|
| POST | `/v1/ledgers/{id}/invites` | → `{"code","expiresAt"}`; код из 10 символов, 7 дней, одноразовый (владелец) |
|
||||||
|
| POST | `/v1/ledgers/join` | `{"code"}` → ledger, `role:"member"` |
|
||||||
|
| DELETE | `/v1/ledgers/{id}/members/{userId}` | владелец удаляет участника; участник — себя (`userId` = свой) |
|
||||||
|
|
||||||
|
- Пространство выбирается заголовком `X-Balance-Ledger: <id>` на
|
||||||
|
`/v1/sync` и `/v1/attachments/*`. Без заголовка — личное пространство.
|
||||||
|
Не участник → `403 forbidden`, несуществующее → `404 not_found`.
|
||||||
|
- Владелец не может выйти (`409 owner_cannot_leave`), даже из пустого
|
||||||
|
пространства — только удалить его.
|
||||||
|
- Ответы: создание пространства и приглашения — `201`; вход по коду и
|
||||||
|
переименование — `200`; удаление пространства и участника — `204`.
|
||||||
|
Повторный вход участника возвращает пространство и не тратит код.
|
||||||
|
`/v1/ledgers/join` ограничен по частоте вместе со входом (30 запросов в
|
||||||
|
минуту с IP).
|
||||||
|
- Клиент, получивший `403`/`404` для выбранного пространства, может сам
|
||||||
|
вернуться в личное (Flutter) или показать ошибку и предложить переключиться
|
||||||
|
(часы).
|
||||||
|
- Все участники равны в правах на записи (last-write-wins как в v1).
|
||||||
|
- Клиент хранит данные пространства отдельно: scope = сервер + пользователь
|
||||||
|
+ пространство. Переключение пространства не переносит записи. Курсор и
|
||||||
|
очередь изменений — свои у каждого пространства.
|
||||||
|
- Ошибки: `invalid_code` (400), `invalid_name` (400: пустое после `trim`,
|
||||||
|
длиннее 100 символов или с управляющими символами), `invalid_json` (400),
|
||||||
|
`forbidden` (403), `not_found` (404, в том числе удаление не-участника),
|
||||||
|
`owner_cannot_leave` (409). Коды приглашений хранятся на сервере только
|
||||||
|
как хеш.
|
||||||
|
|
||||||
|
## 8. Вложения
|
||||||
|
|
||||||
|
| Метод | Путь | Смысл |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PUT | `/v1/attachments/{sha256}` | тело — байты файла, `Content-Type` из списка; сервер проверяет SHA-256 и размер; повтор идемпотентен (`200`, первый раз `201`) |
|
||||||
|
| GET | `/v1/attachments/{sha256}` | байты и `Content-Type`; `404`, если в пространстве нет такого файла |
|
||||||
|
|
||||||
|
- Порядок клиента: сначала PUT байтов, потом запись `attachment` через sync.
|
||||||
|
- Файлы хранятся в пространстве; один и тот же файл в двух пространствах
|
||||||
|
хранится дважды.
|
||||||
|
- Ответ PUT: `{"sha256","size","contentType"}`. GET отдаёт
|
||||||
|
`Content-Disposition: attachment`.
|
||||||
|
- Когда в пространстве не осталось живых записей `attachment` с этим
|
||||||
|
`sha256`, сервер может удалить файл. Файл, на который ещё не ссылалась ни
|
||||||
|
одна запись, хранится не меньше 24 часов после загрузки (клиент загружает
|
||||||
|
байты раньше записи).
|
||||||
|
- Лимит тела 10 МиБ действует только для загрузки вложения; остальные
|
||||||
|
запросы — 2 МиБ, как в v1.
|
||||||
|
- Ошибки: `payload_too_large` (413), `unsupported_media_type` (415),
|
||||||
|
`hash_mismatch` (400, в том числе неверный формат хеша в пути).
|
||||||
|
- Клиент скачивает файл лениво при просмотре и кеширует локально.
|
||||||
|
- Сообщение ошибки для незнакомой сущности на сервере осталось как в v1
|
||||||
|
(«entity must be transaction, category, or budget») ради совместимости с
|
||||||
|
записанными ответами; код `invalid_change` верный.
|
||||||
|
|
||||||
|
## 9. Совместимость с клиентами v1
|
||||||
|
|
||||||
|
- Старый клиент не видит счета, переводы, цели, долги, курсы, вложения и
|
||||||
|
общие пространства; транзакции в других валютах складывает как есть.
|
||||||
|
- При редактировании транзакции старый клиент теряет `accountId`,
|
||||||
|
`currency`, `tags`, `recurringId`, `occurrence`. Поэтому все клиенты
|
||||||
|
экосистемы обновляются вместе с сервером.
|
||||||
|
- Нативные iOS/macOS и Android-клиенты (не входят в эти репозитории) остаются
|
||||||
|
на v1 до своего обновления.
|
||||||
+35
-24
@@ -2,38 +2,25 @@
|
|||||||
|
|
||||||
Ниже описана серверная модель на основании исходного кода проекта. При изменении schema обновляйте этот документ.
|
Ниже описана серверная модель на основании исходного кода проекта. При изменении schema обновляйте этот документ.
|
||||||
|
|
||||||
## Найденные определения моделей/schema
|
## Где определена schema
|
||||||
|
|
||||||
- internal/database/database.go
|
- `src/database.rs` — создание и открытие схемы SQLite;
|
||||||
- internal/database/database.go
|
- `tests/fixtures/go_schema_v1.sql` — исходная схема Go-сервера v1.0.0; Rust обязан открывать такие базы без изменений;
|
||||||
- internal/database/database.go
|
- `src/sync.rs` — валидация и сериализация синхронизируемых записей;
|
||||||
- internal/config/config.go
|
- `src/auth.rs` — пользователи, пароли и refresh-сессии.
|
||||||
- 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:
|
Сервер принимает сущности контракта v2 (`docs/CONTRACT_V2.md` §1):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
transaction
|
transaction category budget account transfer recurring
|
||||||
category
|
goal debt rate setting attachment
|
||||||
budget
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Payload хранится как непрозрачный JSON; сервер читает из него только `sha256`
|
||||||
|
записей `attachment` для сборки мусора файлов. Ниже — сущности v1.
|
||||||
|
|
||||||
### Transaction
|
### Transaction
|
||||||
|
|
||||||
Логически содержит:
|
Логически содержит:
|
||||||
@@ -112,6 +99,30 @@ payload
|
|||||||
7. Timestamps должны храниться в UTC.
|
7. Timestamps должны храниться в UTC.
|
||||||
8. Enum/string values должны быть совместимы с iOS и Android.
|
8. Enum/string values должны быть совместимы с iOS и Android.
|
||||||
|
|
||||||
|
## Таблицы контракта v2
|
||||||
|
|
||||||
|
Таблицы v1 (`users`, `refresh_tokens`, `records`, `changes`) не меняются. Миграция
|
||||||
|
только добавляет таблицы через `CREATE TABLE IF NOT EXISTS`, поэтому существующая
|
||||||
|
база (в том числе Go v1) открывается без переписывания строк и `sequence`.
|
||||||
|
|
||||||
|
| Таблица | Ключ | Назначение |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ledgers` | `id` (UUID) | имя, `owner_id`, `created_at` |
|
||||||
|
| `ledger_members` | `(ledger_id, user_id)`, индекс `user_id` | `role` = `owner`/`member`, `joined_at` |
|
||||||
|
| `ledger_invites` | `code_hash` (SHA-256 кода), индекс `ledger_id` | `expires_at`, `used_at`, `used_by`; код в открытом виде не хранится |
|
||||||
|
| `ledger_records` | `(ledger_id, entity, record_id)` | текущее состояние записей пространства |
|
||||||
|
| `ledger_changes` | `sequence` AUTOINCREMENT, индекс `(ledger_id, sequence)` | поток изменений пространства |
|
||||||
|
| `attachment_blobs` | `(space, sha256)` | метаданные файлов: `content_type`, `size`, `uploaded_at`, `referenced` |
|
||||||
|
|
||||||
|
`space` в `attachment_blobs` — `user:<userId>` или `ledger:<ledgerId>`. Все таблицы
|
||||||
|
ledger ссылаются на `ledgers(id) ON DELETE CASCADE`; строки `attachment_blobs`
|
||||||
|
пространства удаляются в той же транзакции, что и ledger.
|
||||||
|
|
||||||
|
Файлы лежат в `BALANCE_ATTACHMENTS_DIR` (по умолчанию `attachments` рядом с
|
||||||
|
файлом БД): `users/<userId>/<sha[0:2]>/<sha>` и
|
||||||
|
`ledgers/<ledgerId>/<sha[0:2]>/<sha>`. Каталоги создаются с правами `0750`.
|
||||||
|
Один и тот же файл в двух пространствах хранится дважды.
|
||||||
|
|
||||||
## Миграции
|
## Миграции
|
||||||
|
|
||||||
Schema changes должны быть backward-compatible с активными версиями клиентов либо сопровождаться versioned API/миграцией.
|
Schema changes должны быть backward-compatible с активными версиями клиентов либо сопровождаться versioned API/миграцией.
|
||||||
|
|||||||
+12
-11
@@ -3,9 +3,9 @@
|
|||||||
## Runtime and configuration
|
## Runtime and configuration
|
||||||
|
|
||||||
The production executable is the Rust server, built with `cargo build --locked
|
The production executable is the Rust server, built with `cargo build --locked
|
||||||
--release`. SQLite is bundled into the executable. The Go implementation remains
|
--release`. SQLite is bundled into the executable. The original Go implementation
|
||||||
in `cmd/balance-server` and `internal/` as a compatibility reference and rollback
|
was removed after v1.0.0; the `v1.0.0` tag still contains its sources for the
|
||||||
option; build it with `go build -o balance-server-go ./cmd/balance-server`.
|
migration and rollback below (`go build -o balance-server-go ./cmd/balance-server`).
|
||||||
|
|
||||||
The Dockerfile uses a Rust 1.90 Debian Bookworm builder and a Debian Bookworm slim
|
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
|
runtime, running as the unprivileged `balance` user. The image embeds the existing
|
||||||
@@ -35,6 +35,7 @@ for remote access; pass `X-Forwarded-Proto: https` to enable Secure session cook
|
|||||||
| `BALANCE_REFRESH_TTL` | `720h` | Go-style duration for refresh tokens |
|
| `BALANCE_REFRESH_TTL` | `720h` | Go-style duration for refresh tokens |
|
||||||
| `BALANCE_ALLOW_REGISTRATION` | `true` | Enable creation of accounts |
|
| `BALANCE_ALLOW_REGISTRATION` | `true` | Enable creation of accounts |
|
||||||
| `BALANCE_CORS_ORIGINS` | empty | Comma-separated allowed browser origins |
|
| `BALANCE_CORS_ORIGINS` | empty | Comma-separated allowed browser origins |
|
||||||
|
| `BALANCE_ATTACHMENTS_DIR` | `attachments` beside the database | Attachment files; Docker uses `/data/attachments` inside the same volume. Back it up with the database |
|
||||||
|
|
||||||
Inject secrets through the deployment environment or `.env`; never put them in
|
Inject secrets through the deployment environment or `.env`; never put them in
|
||||||
images or source control. Compose preserves the existing named `balance-data`
|
images or source control. Compose preserves the existing named `balance-data`
|
||||||
@@ -65,8 +66,9 @@ required. Do not start both implementations against the same database at once.
|
|||||||
Stop Rust first. Start the retained Go executable/image with the same current
|
Stop Rust first. Start the retained Go executable/image with the same current
|
||||||
database directory and secret. Rust-written accounts, password hashes, refresh
|
database directory and secret. Rust-written accounts, password hashes, refresh
|
||||||
sessions, records, tombstones and cursors remain readable by Go. Check health,
|
sessions, records, tombstones and cursors remain readable by Go. Check health,
|
||||||
login, refresh and sync again. The compatibility suite exercises both directions,
|
login, refresh and sync again. Before the Go sources were removed, the
|
||||||
including a second takeover back to the original implementation.
|
compatibility suite verified takeover and rollback in both directions; Rust still
|
||||||
|
writes the same schema (`tests/fixtures/go_schema_v1.sql`).
|
||||||
|
|
||||||
For database corruption or an unrelated failed migration, stop all writers before
|
For database corruption or an unrelated failed migration, stop all writers before
|
||||||
restoring the complete backup directory. Restoring an older backup discards
|
restoring the complete backup directory. Restoring an older backup discards
|
||||||
@@ -77,12 +79,11 @@ an active database or delete a Compose volume as part of an upgrade.
|
|||||||
## Validation and operations
|
## Validation and operations
|
||||||
|
|
||||||
Before release run `cargo test --locked`, `cargo build --locked --release`,
|
Before release run `cargo test --locked`, `cargo build --locked --release`,
|
||||||
`python3 tools/compatibility.py --rust-binary target/release/balance-server`, and
|
and `python3 tools/compatibility.py --binary target/release/balance-server`.
|
||||||
the retained reference checks `go test ./...`, `go vet ./...`, `go build ./...`.
|
The suite uses temporary databases and loopback listeners, compares
|
||||||
The differential suite uses temporary databases and loopback listeners, compares
|
API/errors/security headers/assets with the recorded Go reference, tests browser
|
||||||
API/errors/security headers/assets, tests browser cookies, pagination, LWW,
|
cookies, pagination, LWW, tombstones, concurrent pushes, token expiration, refresh
|
||||||
tombstones, concurrent pushes, token expiration, refresh rotation, rate limiting,
|
rotation, rate limiting, and SQLite persistence across restarts.
|
||||||
and shared-database takeover/rollback in both directions.
|
|
||||||
|
|
||||||
Keep regular encrypted backups with retention and restore drills. Monitor HTTP
|
Keep regular encrypted backups with retention and restore drills. Monitor HTTP
|
||||||
5xx, sync errors, authentication failures, latency, database/change-log growth
|
5xx, sync errors, authentication failures, latency, database/change-log growth
|
||||||
|
|||||||
+8
-9
@@ -2,28 +2,26 @@
|
|||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
Production-сервер использует Rust 1.90+ и bundled SQLite. Go 1.25+ нужен для сохранённой эталонной реализации; differential-проверки (`tools/compatibility.py`) требуют Go 1.27.3, как в CI: поведение `net/http` (например, код редиректа ServeMux 301/307) зависит от версии Go. Python 3 используется без сторонних пакетов.
|
Сервер использует Rust 1.90+ и bundled SQLite. Python 3 нужен для `tools/compatibility.py` и используется без сторонних пакетов. Go больше не нужен.
|
||||||
|
|
||||||
Проверить локальную версию:
|
Проверить локальную версию:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rustc --version
|
rustc --version
|
||||||
cargo --version
|
cargo --version
|
||||||
go version
|
python3 --version
|
||||||
```
|
```
|
||||||
|
|
||||||
## Установка зависимостей
|
## Установка зависимостей
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fetch --locked
|
cargo fetch --locked
|
||||||
go mod download
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --locked
|
cargo build --locked
|
||||||
go build ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
@@ -32,13 +30,15 @@ go build ./...
|
|||||||
cargo test --locked
|
cargo test --locked
|
||||||
cargo build --locked
|
cargo build --locked
|
||||||
python3 tools/compatibility.py
|
python3 tools/compatibility.py
|
||||||
go test ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
С race detector:
|
`tools/compatibility.py` запускает сервер на временных портах и базах и сравнивает
|
||||||
|
ответы API и статику с ответами исходного Go-сервера, записанными в
|
||||||
|
`tools/compatibility_expected.txt`. Расхождение означает изменение, видимое
|
||||||
|
клиентам. Если изменение намеренное, проверьте diff и перезапишите эталон:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test -race ./...
|
python3 tools/compatibility.py --update
|
||||||
```
|
```
|
||||||
|
|
||||||
## Static checks
|
## Static checks
|
||||||
@@ -46,7 +46,6 @@ go test -race ./...
|
|||||||
```bash
|
```bash
|
||||||
cargo fmt --check
|
cargo fmt --check
|
||||||
cargo clippy --locked --all-targets -- -D warnings
|
cargo clippy --locked --all-targets -- -D warnings
|
||||||
go vet ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Если в проекте есть Makefile/CI, его команды имеют приоритет как canonical build pipeline.
|
Если в проекте есть Makefile/CI, его команды имеют приоритет как canonical build pipeline.
|
||||||
@@ -89,7 +88,7 @@ cargo clippy --locked --all-targets -- -D warnings
|
|||||||
↓
|
↓
|
||||||
cargo build --locked --release
|
cargo build --locked --release
|
||||||
↓
|
↓
|
||||||
python3 tools/compatibility.py --rust-binary target/release/balance-server
|
python3 tools/compatibility.py --binary target/release/balance-server
|
||||||
↓
|
↓
|
||||||
container/package
|
container/package
|
||||||
↓
|
↓
|
||||||
|
|||||||
@@ -51,6 +51,25 @@ HTTP следует оставлять только для controlled local deve
|
|||||||
- JSON size;
|
- JSON size;
|
||||||
- cursor/sequence bounds.
|
- cursor/sequence bounds.
|
||||||
|
|
||||||
|
## Общие пространства и вложения (контракт v2)
|
||||||
|
|
||||||
|
- Доступ к ledger проверяется на каждый запрос по `ledger_members`; `X-Balance-Ledger`
|
||||||
|
не участника → `403`, неизвестный → `404`. Управлять пространством и выдавать
|
||||||
|
приглашения может только владелец.
|
||||||
|
- Коды приглашений — 10 случайных символов из 32 (50 бит, `OsRng`), одноразовые,
|
||||||
|
7 дней; в БД хранится только SHA-256. `/v1/ledgers/join` ограничен общим
|
||||||
|
лимитом 30 запросов/мин на IP.
|
||||||
|
- Вложения проверяются до записи на диск: формат хеша, тип из белого списка,
|
||||||
|
размер ≤ 10 MiB и SHA-256 тела. Файл пишется во временный файл и
|
||||||
|
переименовывается. Тело больше 2 MiB читается только после проверки
|
||||||
|
access token. Путь к файлу строится из проверенного hex-хеша и id
|
||||||
|
пространства (небезопасный id заменяется его хешем), поэтому значения клиента
|
||||||
|
не становятся компонентами пути.
|
||||||
|
- Файлы отдаются с `Content-Disposition: attachment`, `X-Content-Type-Options:
|
||||||
|
nosniff`, `Cache-Control: no-store` и не отображаются в origin веб-клиента.
|
||||||
|
- Каталог вложений содержит финансовые документы: права `0750`, резервные
|
||||||
|
копии — вместе с БД.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
Безопасно логировать:
|
Безопасно логировать:
|
||||||
|
|||||||
@@ -131,6 +131,35 @@ Server не должен предполагать, что все изменен
|
|||||||
|
|
||||||
Нельзя молча трактовать неизвестный/просроченный cursor как актуальный.
|
Нельзя молча трактовать неизвестный/просроченный cursor как актуальный.
|
||||||
|
|
||||||
|
## Пространства (контракт v2)
|
||||||
|
|
||||||
|
`POST /v1/sync` работает в одном пространстве данных:
|
||||||
|
|
||||||
|
- без `X-Balance-Ledger` — личное пространство пользователя, таблицы
|
||||||
|
`records`/`changes`, поведение v1 без изменений;
|
||||||
|
- с `X-Balance-Ledger: <id>` — общее пространство, таблицы
|
||||||
|
`ledger_records`/`ledger_changes`. Доступ проверяется на каждый запрос.
|
||||||
|
|
||||||
|
У общего пространства свой поток изменений: `sequence` берётся из отдельного
|
||||||
|
AUTOINCREMENT `ledger_changes`, а pull фильтрует по `ledger_id`. Курсор личного
|
||||||
|
пространства и курсор каждого ledger независимы, клиент хранит их раздельно.
|
||||||
|
Все участники пишут в одно пространство с тем же last-write-wins по
|
||||||
|
`updatedAt`; идемпотентность, пагинация (500), лимиты и транзакционность те же.
|
||||||
|
|
||||||
|
Удаление пространства физически удаляет его записи и историю изменений.
|
||||||
|
Удалённый участник теряет доступ (`403`), записи остаются в пространстве.
|
||||||
|
|
||||||
|
## Вложения и сборка мусора
|
||||||
|
|
||||||
|
Байты вложений загружаются отдельно (`PUT /v1/attachments/{sha256}`) до записи
|
||||||
|
`attachment` в sync. Файл пространства считается мусором, когда ни одна живая
|
||||||
|
(`deleted=0`) запись `attachment` этого пространства не ссылается на его
|
||||||
|
`sha256` (сравнение без учёта регистра). Сборка выполняется в той же транзакции,
|
||||||
|
что и sync-запрос, содержащий изменения `attachment`, и при каждом PUT.
|
||||||
|
Файл, ни разу не получивший ссылки, хранится не меньше 24 часов с последней
|
||||||
|
загрузки, чтобы не удалить байты, чья запись ещё не синхронизирована; повторная
|
||||||
|
загрузка перезапускает этот срок.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Sync endpoint всегда ограничен текущим authenticated user.
|
Sync endpoint всегда ограничен текущим authenticated user.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<!-- Repository avatar: the app's wallet icon, full-bleed, with a sync badge
|
||||||
|
marking it as the server. No text or thin lines, so it survives 24-40 px. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#7777ef"/>
|
||||||
|
<stop offset="1" stop-color="#4242b8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="80" y1="140" x2="380" y2="420" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#fff"/>
|
||||||
|
<stop offset="1" stop-color="#dedfff"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="512" height="512" fill="url(#bg)"/>
|
||||||
|
|
||||||
|
<!-- Wallet, scaled up from web/icon.svg -->
|
||||||
|
<g transform="translate(-34 -46) scale(1.12)">
|
||||||
|
<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="14" fill="#fff"/>
|
||||||
|
<path d="M184 250h44M184 289h44M184 328h44" stroke="#5b5bd6" stroke-width="20" stroke-linecap="round" opacity=".72"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Sync badge -->
|
||||||
|
<circle cx="392" cy="392" r="92" fill="#16a36a" stroke="#4a4ac4" stroke-width="16"/>
|
||||||
|
<g fill="none" stroke="#fff" stroke-width="20" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M346 380a48 48 0 0 1 82-26"/>
|
||||||
|
<path d="M438 404a48 48 0 0 1-82 26"/>
|
||||||
|
</g>
|
||||||
|
<path d="M440 324v42h-42z" fill="#fff" stroke="#fff" stroke-width="6" stroke-linejoin="round"/>
|
||||||
|
<path d="M344 460v-42h42z" fill="#fff" stroke="#fff" stroke-width="6" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1,82 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glow" cx="512" cy="400" r="520" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#5b5bd6" stop-opacity=".42"/>
|
||||||
|
<stop offset=".55" stop-color="#5b5bd6" stop-opacity=".08"/>
|
||||||
|
<stop offset="1" stop-color="#5b5bd6" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1024" y2="1024" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#15151c"/>
|
||||||
|
<stop offset="1" stop-color="#0d0d12"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="iconbg" 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>
|
||||||
|
<pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="2" cy="2" r="1.4" fill="#ffffff" opacity=".06"/>
|
||||||
|
</pattern>
|
||||||
|
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feDropShadow dx="0" dy="24" stdDeviation="28" flood-color="#000" flood-opacity=".55"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="1024" height="1024" fill="url(#bg)"/>
|
||||||
|
<rect width="1024" height="1024" fill="url(#dots)"/>
|
||||||
|
<rect width="1024" height="1024" fill="url(#glow)"/>
|
||||||
|
|
||||||
|
<!-- Sync orbits around the app icon, with client devices on them -->
|
||||||
|
<g fill="none" stroke="#8b8bff" stroke-linecap="round">
|
||||||
|
<circle cx="512" cy="400" r="215" stroke-opacity=".22" stroke-width="2" stroke-dasharray="2 12"/>
|
||||||
|
<circle cx="512" cy="400" r="290" stroke-opacity=".12" stroke-width="2"/>
|
||||||
|
<path d="M512 110a290 290 0 0 1 251 145" stroke-opacity=".7" stroke-width="3"/>
|
||||||
|
<path d="M512 690a290 290 0 0 1-251-145" stroke-opacity=".7" stroke-width="3"/>
|
||||||
|
</g>
|
||||||
|
<path d="M763 255l-2-21 19 9z" fill="#8b8bff" opacity=".8"/>
|
||||||
|
<path d="M261 545l2 21-19-9z" fill="#8b8bff" opacity=".8"/>
|
||||||
|
<g font-family="JetBrains Mono" font-size="17" font-weight="600" fill="#c9c9ff" text-anchor="middle">
|
||||||
|
<g transform="translate(512 185)"><rect x="-48" y="-19" width="96" height="38" rx="19" fill="#1f1f2b" stroke="#3b3b55"/><text y="6">iOS</text></g>
|
||||||
|
<g transform="translate(727 400)"><rect x="-56" y="-19" width="112" height="38" rx="19" fill="#1f1f2b" stroke="#3b3b55"/><text y="6">macOS</text></g>
|
||||||
|
<g transform="translate(512 615)"><rect x="-64" y="-19" width="128" height="38" rx="19" fill="#1f1f2b" stroke="#3b3b55"/><text y="6">Android</text></g>
|
||||||
|
<g transform="translate(297 400)"><rect x="-48" y="-19" width="96" height="38" rx="19" fill="#1f1f2b" stroke="#3b3b55"/><text y="6">Web</text></g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- App icon (web/icon.svg) -->
|
||||||
|
<g transform="translate(384 272) scale(.5)" filter="url(#shadow)">
|
||||||
|
<rect x="28" y="28" width="456" height="456" rx="128" fill="url(#iconbg)"/>
|
||||||
|
<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"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Title block -->
|
||||||
|
<g font-family="Adwaita Sans" text-anchor="middle">
|
||||||
|
<text x="512" y="808" font-size="92" font-weight="800" letter-spacing="-2.5" fill="#f5f5f7">Balance <tspan fill="#9d9dff">Server</tspan></text>
|
||||||
|
<text x="512" y="862" font-size="29" font-weight="500" fill="#a4a5b2">Self-hosted sync & accounts for Balance</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g font-family="JetBrains Mono" font-size="18" font-weight="600" text-anchor="middle">
|
||||||
|
<g transform="translate(275 904)">
|
||||||
|
<rect width="100" height="42" rx="11" fill="#2a1f1a" stroke="#5a3a28"/>
|
||||||
|
<text x="50" y="27" fill="#f2a46b">Rust</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(389 904)">
|
||||||
|
<rect width="116" height="42" rx="11" fill="#17222b" stroke="#28465a"/>
|
||||||
|
<text x="58" y="27" fill="#78bde8">SQLite</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(519 904)">
|
||||||
|
<rect width="116" height="42" rx="11" fill="#162329" stroke="#244b5a"/>
|
||||||
|
<text x="58" y="27" fill="#6fd0e8">Docker</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(649 904)">
|
||||||
|
<rect width="100" height="42" rx="11" fill="#14251d" stroke="#235a40"/>
|
||||||
|
<text x="50" y="27" fill="#5fd39b">PWA</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
@@ -0,0 +1,87 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glow" cx="300" cy="320" r="460" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#5b5bd6" stop-opacity=".42"/>
|
||||||
|
<stop offset=".55" stop-color="#5b5bd6" stop-opacity=".08"/>
|
||||||
|
<stop offset="1" stop-color="#5b5bd6" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1280" y2="640" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#15151c"/>
|
||||||
|
<stop offset="1" stop-color="#0d0d12"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="iconbg" 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>
|
||||||
|
<pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="2" cy="2" r="1.4" fill="#ffffff" opacity=".06"/>
|
||||||
|
</pattern>
|
||||||
|
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feDropShadow dx="0" dy="24" stdDeviation="28" flood-color="#000" flood-opacity=".55"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="1280" height="640" fill="url(#bg)"/>
|
||||||
|
<rect width="1280" height="640" fill="url(#dots)"/>
|
||||||
|
<rect width="1280" height="640" fill="url(#glow)"/>
|
||||||
|
|
||||||
|
<!-- Sync orbits around the app icon, with client devices on them -->
|
||||||
|
<g fill="none" stroke="#8b8bff" stroke-linecap="round">
|
||||||
|
<circle cx="300" cy="320" r="215" stroke-opacity=".22" stroke-width="2" stroke-dasharray="2 12"/>
|
||||||
|
<circle cx="300" cy="320" r="268" stroke-opacity=".12" stroke-width="2"/>
|
||||||
|
<path d="M300 52a268 268 0 0 1 232 134" stroke-opacity=".7" stroke-width="3"/>
|
||||||
|
<path d="M300 588a268 268 0 0 1-232-134" stroke-opacity=".7" stroke-width="3"/>
|
||||||
|
</g>
|
||||||
|
<path d="M532 186l-2-20 18 9z" fill="#8b8bff" opacity=".8"/>
|
||||||
|
<path d="M68 454l2 20-18-9z" fill="#8b8bff" opacity=".8"/>
|
||||||
|
<g font-family="JetBrains Mono" font-size="15" font-weight="600" fill="#c9c9ff" text-anchor="middle">
|
||||||
|
<g transform="translate(300 105)"><rect x="-44" y="-17" width="88" height="34" rx="17" fill="#1f1f2b" stroke="#3b3b55"/><text y="5">iOS</text></g>
|
||||||
|
<g transform="translate(486 320)"><rect x="-50" y="-17" width="100" height="34" rx="17" fill="#1f1f2b" stroke="#3b3b55"/><text y="5">macOS</text></g>
|
||||||
|
<g transform="translate(300 535)"><rect x="-58" y="-17" width="116" height="34" rx="17" fill="#1f1f2b" stroke="#3b3b55"/><text y="5">Android</text></g>
|
||||||
|
<g transform="translate(114 320)"><rect x="-44" y="-17" width="88" height="34" rx="17" fill="#1f1f2b" stroke="#3b3b55"/><text y="5">Web</text></g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- App icon (web/icon.svg) -->
|
||||||
|
<g transform="translate(180 200) scale(.46875)" filter="url(#shadow)">
|
||||||
|
<rect x="28" y="28" width="456" height="456" rx="128" fill="url(#iconbg)"/>
|
||||||
|
<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"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Title block -->
|
||||||
|
<g font-family="Adwaita Sans" fill="#f5f5f7">
|
||||||
|
<text x="640" y="250" font-size="88" font-weight="800" letter-spacing="-2.5">Balance</text>
|
||||||
|
<text x="640" y="342" font-size="88" font-weight="800" letter-spacing="-2.5" fill="#9d9dff">Server</text>
|
||||||
|
<text x="642" y="398" font-size="27" font-weight="500" fill="#a4a5b2">Self-hosted sync & accounts for Balance</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g font-family="JetBrains Mono" font-size="17" font-weight="600">
|
||||||
|
<g transform="translate(642 446)">
|
||||||
|
<rect width="96" height="40" rx="10" fill="#2a1f1a" stroke="#5a3a28"/>
|
||||||
|
<text x="48" y="26" text-anchor="middle" fill="#f2a46b">Rust</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(752 446)">
|
||||||
|
<rect width="112" height="40" rx="10" fill="#17222b" stroke="#28465a"/>
|
||||||
|
<text x="56" y="26" text-anchor="middle" fill="#78bde8">SQLite</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(878 446)">
|
||||||
|
<rect width="112" height="40" rx="10" fill="#162329" stroke="#244b5a"/>
|
||||||
|
<text x="56" y="26" text-anchor="middle" fill="#6fd0e8">Docker</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(1004 446)">
|
||||||
|
<rect width="88" height="40" rx="10" fill="#14251d" stroke="#235a40"/>
|
||||||
|
<text x="44" y="26" text-anchor="middle" fill="#5fd39b">PWA</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="642" y="540" font-family="JetBrains Mono" font-size="18" fill="#6c6d7c">
|
||||||
|
<tspan fill="#9d9dff">POST</tspan> /v1/sync <tspan fill="#4a4b58">→</tspan> <tspan fill="#5fd39b">200 OK</tspan>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -1,21 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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=
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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) }
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package httpapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const maxSyncChanges = 5000
|
|
||||||
const syncPageSize = 500
|
|
||||||
|
|
||||||
type syncChange struct {
|
|
||||||
Entity string `json:"entity"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Deleted bool `json:"deleted"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
Payload json.RawMessage `json:"payload,omitempty"`
|
|
||||||
Version int64 `json:"version,omitempty"`
|
|
||||||
Sequence int64 `json:"sequence,omitempty"`
|
|
||||||
}
|
|
||||||
type syncRequest struct {
|
|
||||||
Cursor int64 `json:"cursor"`
|
|
||||||
DeviceID string `json:"deviceId"`
|
|
||||||
Changes []syncChange `json:"changes"`
|
|
||||||
Pull *bool `json:"pull,omitempty"`
|
|
||||||
}
|
|
||||||
type syncResponse struct {
|
|
||||||
Cursor int64 `json:"cursor"`
|
|
||||||
Changes []syncChange `json:"changes"`
|
|
||||||
ServerTime time.Time `json:"serverTime"`
|
|
||||||
HasMore bool `json:"hasMore"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) sync(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var request syncRequest
|
|
||||||
if !decodeJSON(w, r, &request) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if request.Cursor < 0 || len(request.Changes) > maxSyncChanges {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_sync_request", "Cursor or change count is invalid")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for index := range request.Changes {
|
|
||||||
if err := validateChange(request.Changes[index]); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid_change", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
userID := claims(r).Subject
|
|
||||||
tx, err := api.db.BeginTx(r.Context(), nil)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not begin synchronization")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
for _, change := range request.Changes {
|
|
||||||
if err := applyChange(r, tx, userID, change); err != nil {
|
|
||||||
api.logger.Error("apply sync change", "error", err)
|
|
||||||
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not apply changes")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
changes := make([]syncChange, 0)
|
|
||||||
cursor := request.Cursor
|
|
||||||
hasMore := false
|
|
||||||
shouldPull := request.Pull == nil || *request.Pull
|
|
||||||
if shouldPull {
|
|
||||||
changes, cursor, hasMore, err = loadChanges(r, tx, userID, request.Cursor)
|
|
||||||
if err != nil {
|
|
||||||
api.logger.Error("load sync changes", "error", err)
|
|
||||||
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not load changes")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "sync_failed", "Could not complete synchronization")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, syncResponse{Cursor: cursor, Changes: changes, ServerTime: time.Now().UTC(), HasMore: hasMore})
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateChange(change syncChange) error {
|
|
||||||
if change.Entity != "transaction" && change.Entity != "category" && change.Entity != "budget" {
|
|
||||||
return errors.New("entity must be transaction, category, or budget")
|
|
||||||
}
|
|
||||||
if len(change.ID) < 1 || len(change.ID) > 128 {
|
|
||||||
return errors.New("record id is invalid")
|
|
||||||
}
|
|
||||||
if change.UpdatedAt.IsZero() {
|
|
||||||
return errors.New("updatedAt is required")
|
|
||||||
}
|
|
||||||
if change.UpdatedAt.After(time.Now().UTC().Add(24 * time.Hour)) {
|
|
||||||
return errors.New("updatedAt is too far in the future")
|
|
||||||
}
|
|
||||||
if !change.Deleted && (len(change.Payload) == 0 || !json.Valid(change.Payload)) {
|
|
||||||
return errors.New("payload is required for active records")
|
|
||||||
}
|
|
||||||
if len(change.Payload) > 256*1024 {
|
|
||||||
return errors.New("payload is too large")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyChange(r *http.Request, tx *sql.Tx, userID string, incoming syncChange) error {
|
|
||||||
var currentUpdated string
|
|
||||||
var currentVersion int64
|
|
||||||
err := tx.QueryRowContext(r.Context(), `SELECT updated_at,version FROM records WHERE user_id=? AND entity=? AND record_id=?`, userID, incoming.Entity, incoming.ID).Scan(¤tUpdated, ¤tVersion)
|
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
parsed, parseErr := time.Parse(time.RFC3339Nano, currentUpdated)
|
|
||||||
if parseErr != nil {
|
|
||||||
return parseErr
|
|
||||||
}
|
|
||||||
if !incoming.UpdatedAt.After(parsed) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
version := currentVersion + 1
|
|
||||||
updated := incoming.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
|
||||||
var payload any
|
|
||||||
if !incoming.Deleted {
|
|
||||||
payload = string(incoming.Payload)
|
|
||||||
}
|
|
||||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO records(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?)
|
|
||||||
ON CONFLICT(user_id,entity,record_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at,deleted=excluded.deleted,version=excluded.version`,
|
|
||||||
userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?)`, userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadChanges(r *http.Request, tx *sql.Tx, userID string, after int64) ([]syncChange, int64, bool, error) {
|
|
||||||
rows, err := tx.QueryContext(r.Context(), `SELECT sequence,entity,record_id,payload,updated_at,deleted,version FROM changes WHERE user_id=? AND sequence>? ORDER BY sequence ASC LIMIT ?`, userID, after, syncPageSize+1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, after, false, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
result := make([]syncChange, 0)
|
|
||||||
cursor := after
|
|
||||||
hasMore := false
|
|
||||||
for rows.Next() {
|
|
||||||
if len(result) == syncPageSize {
|
|
||||||
hasMore = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
var item syncChange
|
|
||||||
var payload sql.NullString
|
|
||||||
var updated string
|
|
||||||
if err := rows.Scan(&item.Sequence, &item.Entity, &item.ID, &payload, &updated, &item.Deleted, &item.Version); err != nil {
|
|
||||||
return nil, after, false, err
|
|
||||||
}
|
|
||||||
item.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated)
|
|
||||||
if err != nil {
|
|
||||||
return nil, after, false, err
|
|
||||||
}
|
|
||||||
if payload.Valid {
|
|
||||||
item.Payload = json.RawMessage(payload.String)
|
|
||||||
}
|
|
||||||
cursor = item.Sequence
|
|
||||||
result = append(result, item)
|
|
||||||
}
|
|
||||||
return result, cursor, hasMore, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
//! Attachment blobs (contract v2, section 8).
|
||||||
|
//!
|
||||||
|
//! Bytes live on disk under the attachments directory, one subdirectory per
|
||||||
|
//! data space; their metadata lives in `attachment_blobs`. A blob is garbage
|
||||||
|
//! when no live `attachment` record of its space references its SHA-256.
|
||||||
|
//! Blobs that were uploaded but not referenced yet are kept for a grace period,
|
||||||
|
//! because clients upload the bytes before syncing the record.
|
||||||
|
|
||||||
|
use crate::sync::Space;
|
||||||
|
use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
|
||||||
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
io::{self, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MAX_BYTES: usize = 10 * 1024 * 1024;
|
||||||
|
pub const TYPES: [&str; 4] = ["image/jpeg", "image/png", "image/heic", "application/pdf"];
|
||||||
|
/// How long an uploaded, never referenced blob survives garbage collection.
|
||||||
|
pub const GRACE: TimeDelta = TimeDelta::hours(24);
|
||||||
|
|
||||||
|
pub struct Store {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { dir: dir.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn space_dir(&self, space: &Space) -> PathBuf {
|
||||||
|
let (kind, id) = match space {
|
||||||
|
Space::User(id) => ("users", id.as_str()),
|
||||||
|
Space::Ledger(id) => ("ledgers", id.as_str()),
|
||||||
|
};
|
||||||
|
// Server-generated ids are hex/UUIDs; anything else is hashed so a
|
||||||
|
// database value can never become a path component such as "..".
|
||||||
|
let safe = !id.is_empty()
|
||||||
|
&& id.len() <= 64
|
||||||
|
&& id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
|
||||||
|
let name = if safe {
|
||||||
|
id.to_owned()
|
||||||
|
} else {
|
||||||
|
crate::auth::token_hash(id)
|
||||||
|
};
|
||||||
|
self.dir.join(kind).join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self, space: &Space, sha: &str) -> PathBuf {
|
||||||
|
self.space_dir(space).join(&sha[..2]).join(sha)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write bytes to a temporary file of the space, ready to be renamed.
|
||||||
|
fn stage(&self, space: &Space, bytes: &[u8]) -> io::Result<PathBuf> {
|
||||||
|
let dir = self.space_dir(space);
|
||||||
|
create_dir(&dir)?;
|
||||||
|
let name = crate::auth::identifier().map_err(io::Error::other)?;
|
||||||
|
let path = dir.join(format!(".upload-{name}"));
|
||||||
|
let mut file = fs::File::create(&path)?;
|
||||||
|
let written = file.write_all(bytes).and_then(|_| file.sync_all());
|
||||||
|
if let Err(e) = written {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, staged: &Path, space: &Space, sha: &str) -> io::Result<()> {
|
||||||
|
let path = self.path(space, sha);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
create_dir(parent)?;
|
||||||
|
}
|
||||||
|
fs::rename(staged, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read(&self, space: &Space, sha: &str) -> io::Result<Vec<u8>> {
|
||||||
|
fs::read(self.path(space, sha))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove blob files whose rows were already deleted.
|
||||||
|
pub fn remove(&self, space: &Space, shas: &[String]) {
|
||||||
|
for sha in shas {
|
||||||
|
if valid_sha(sha) {
|
||||||
|
match fs::remove_file(self.path(space, sha)) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => eprintln!("attachment cleanup failed: {}", e.kind()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove every blob of a deleted space.
|
||||||
|
pub fn remove_space(&self, space: &Space) {
|
||||||
|
match fs::remove_dir_all(self.space_dir(space)) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => eprintln!("attachment directory cleanup failed: {}", e.kind()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_dir(path: &Path) -> io::Result<()> {
|
||||||
|
let mut builder = fs::DirBuilder::new();
|
||||||
|
builder.recursive(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
|
builder.mode(0o750);
|
||||||
|
}
|
||||||
|
builder.create(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 in the canonical form of the contract: 64 lowercase hex digits.
|
||||||
|
pub fn valid_sha(sha: &str) -> bool {
|
||||||
|
sha.len() == 64 && sha.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepted media type of a `Content-Type` header, ignoring parameters.
|
||||||
|
pub fn media_type(header: &str) -> Option<&'static str> {
|
||||||
|
let essence = header.split(';').next()?.trim().to_ascii_lowercase();
|
||||||
|
TYPES.into_iter().find(|t| *t == essence)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stamp(t: DateTime<Utc>) -> String {
|
||||||
|
// Fixed width, so stored stamps compare correctly as text.
|
||||||
|
t.to_rfc3339_opts(SecondsFormat::Nanos, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn live_shas(space: &Space) -> String {
|
||||||
|
let (records, _, key) = space.tables();
|
||||||
|
format!(
|
||||||
|
"SELECT sha FROM (SELECT lower(CASE WHEN json_valid(payload) THEN json_extract(payload,'$.sha256') END) AS sha FROM {records} WHERE {key}=?2 AND entity='attachment' AND deleted=0) WHERE sha IS NOT NULL"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete rows of blobs no live attachment record references and return
|
||||||
|
/// their hashes. Blobs never referenced survive for [`GRACE`] after upload.
|
||||||
|
pub fn collect_garbage(
|
||||||
|
db: &Connection,
|
||||||
|
space: &Space,
|
||||||
|
now: DateTime<Utc>,
|
||||||
|
) -> rusqlite::Result<Vec<String>> {
|
||||||
|
let live = live_shas(space);
|
||||||
|
let key = space.key();
|
||||||
|
db.execute(
|
||||||
|
&format!(
|
||||||
|
"UPDATE attachment_blobs SET referenced=1 WHERE space=?1 AND referenced=0 AND sha256 IN ({live})"
|
||||||
|
),
|
||||||
|
params![key, space.id()],
|
||||||
|
)?;
|
||||||
|
let condition =
|
||||||
|
format!("space=?1 AND (referenced=1 OR uploaded_at<?3) AND sha256 NOT IN ({live})");
|
||||||
|
let garbage = db
|
||||||
|
.prepare(&format!(
|
||||||
|
"SELECT sha256 FROM attachment_blobs WHERE {condition}"
|
||||||
|
))?
|
||||||
|
.query_map(params![key, space.id(), stamp(now - GRACE)], |r| r.get(0))?
|
||||||
|
.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||||
|
db.execute(
|
||||||
|
&format!("DELETE FROM attachment_blobs WHERE {condition}"),
|
||||||
|
params![key, space.id(), stamp(now - GRACE)],
|
||||||
|
)?;
|
||||||
|
Ok(garbage)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum PutError {
|
||||||
|
HashMismatch,
|
||||||
|
UnsupportedMediaType,
|
||||||
|
PayloadTooLarge,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store bytes in `space`. Returns `true` when the blob was created and
|
||||||
|
/// `false` for an idempotent repeat. The database lock is taken only to
|
||||||
|
/// install the already written file.
|
||||||
|
pub fn put(
|
||||||
|
db: &std::sync::Mutex<Connection>,
|
||||||
|
store: &Store,
|
||||||
|
space: &Space,
|
||||||
|
sha: &str,
|
||||||
|
content_type: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<bool, PutError> {
|
||||||
|
if !valid_sha(sha) {
|
||||||
|
return Err(PutError::HashMismatch);
|
||||||
|
}
|
||||||
|
let Some(media) = media_type(content_type) else {
|
||||||
|
return Err(PutError::UnsupportedMediaType);
|
||||||
|
};
|
||||||
|
if bytes.len() > MAX_BYTES {
|
||||||
|
return Err(PutError::PayloadTooLarge);
|
||||||
|
}
|
||||||
|
let digest: String = Sha256::digest(bytes)
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect();
|
||||||
|
if digest != sha {
|
||||||
|
return Err(PutError::HashMismatch);
|
||||||
|
}
|
||||||
|
let staged = store.stage(space, bytes).map_err(|_| PutError::Internal)?;
|
||||||
|
let result = install(db, store, space, sha, media, bytes.len(), &staged);
|
||||||
|
let _ = fs::remove_file(&staged);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(
|
||||||
|
db: &std::sync::Mutex<Connection>,
|
||||||
|
store: &Store,
|
||||||
|
space: &Space,
|
||||||
|
sha: &str,
|
||||||
|
media: &str,
|
||||||
|
size: usize,
|
||||||
|
staged: &Path,
|
||||||
|
) -> Result<bool, PutError> {
|
||||||
|
let internal = |_| PutError::Internal;
|
||||||
|
let mut db = db.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let tx = db.transaction().map_err(internal)?;
|
||||||
|
let now = Utc::now();
|
||||||
|
let key = space.key();
|
||||||
|
let exists = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM attachment_blobs WHERE space=? AND sha256=?",
|
||||||
|
params![key, sha],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(internal)?
|
||||||
|
.is_some();
|
||||||
|
let path = store.path(space, sha);
|
||||||
|
let file_missing = !path.is_file();
|
||||||
|
if exists {
|
||||||
|
// A repeated upload restarts the grace period: another device may
|
||||||
|
// be about to sync a new record that references these bytes.
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE attachment_blobs SET uploaded_at=?, referenced=0 WHERE space=? AND sha256=?",
|
||||||
|
params![stamp(now), key, sha],
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
} else {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO attachment_blobs(space,sha256,content_type,size,uploaded_at,referenced) VALUES(?,?,?,?,?,0)",
|
||||||
|
params![key, sha, media, size as i64, stamp(now)],
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
}
|
||||||
|
let garbage = collect_garbage(&tx, space, now).map_err(internal)?;
|
||||||
|
if file_missing {
|
||||||
|
store
|
||||||
|
.install(staged, space, sha)
|
||||||
|
.map_err(|_| PutError::Internal)?;
|
||||||
|
}
|
||||||
|
if tx.commit().is_err() {
|
||||||
|
if file_missing {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
return Err(PutError::Internal);
|
||||||
|
}
|
||||||
|
store.remove(space, &garbage);
|
||||||
|
Ok(!exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes and media type of a blob stored in `space`.
|
||||||
|
pub fn get(
|
||||||
|
db: &std::sync::Mutex<Connection>,
|
||||||
|
store: &Store,
|
||||||
|
space: &Space,
|
||||||
|
sha: &str,
|
||||||
|
) -> Result<Option<(Vec<u8>, String)>, ()> {
|
||||||
|
if !valid_sha(sha) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let db = db.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let media: Option<String> = db
|
||||||
|
.query_row(
|
||||||
|
"SELECT content_type FROM attachment_blobs WHERE space=? AND sha256=?",
|
||||||
|
params![space.key(), sha],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
let Some(media) = media else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
// Reading under the lock keeps garbage collection from removing the
|
||||||
|
// file between the metadata check and the read.
|
||||||
|
match store.read(space, sha) {
|
||||||
|
Ok(bytes) => Ok(Some((bytes, media))),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(_) => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashes_and_media_types_are_strict() {
|
||||||
|
assert!(valid_sha(&"a".repeat(64)));
|
||||||
|
assert!(!valid_sha(&"A".repeat(64)));
|
||||||
|
assert!(!valid_sha(&"a".repeat(63)));
|
||||||
|
assert!(!valid_sha(&format!("{}g", "a".repeat(63))));
|
||||||
|
assert_eq!(media_type("IMAGE/JPEG; q=1"), Some("image/jpeg"));
|
||||||
|
assert_eq!(media_type("application/pdf"), Some("application/pdf"));
|
||||||
|
assert_eq!(media_type("image/gif"), None);
|
||||||
|
assert_eq!(media_type(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsafe_space_ids_never_become_path_components() {
|
||||||
|
let store = Store::new("/base");
|
||||||
|
let dir = store.space_dir(&Space::Ledger("../../etc".into()));
|
||||||
|
assert!(dir.starts_with("/base/ledgers"));
|
||||||
|
assert!(!dir.to_string_lossy().contains(".."));
|
||||||
|
assert_eq!(
|
||||||
|
store.space_dir(&Space::User("abc".into())),
|
||||||
|
Path::new("/base/users/abc")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-2
@@ -1,5 +1,8 @@
|
|||||||
use chrono::TimeDelta;
|
use chrono::TimeDelta;
|
||||||
use std::env;
|
use std::{
|
||||||
|
env,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -10,6 +13,7 @@ pub struct Config {
|
|||||||
pub refresh_ttl: TimeDelta,
|
pub refresh_ttl: TimeDelta,
|
||||||
pub allow_registration: bool,
|
pub allow_registration: bool,
|
||||||
pub cors_origins: Vec<String>,
|
pub cors_origins: Vec<String>,
|
||||||
|
pub attachments_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -25,9 +29,14 @@ impl Config {
|
|||||||
if jwt_secret.len() < 32 {
|
if jwt_secret.len() < 32 {
|
||||||
return Err("BALANCE_JWT_SECRET must contain at least 32 characters".into());
|
return Err("BALANCE_JWT_SECRET must contain at least 32 characters".into());
|
||||||
}
|
}
|
||||||
|
let database_path = fallback("BALANCE_DB_PATH", "./data/balance.db");
|
||||||
|
let attachments_dir = match value("BALANCE_ATTACHMENTS_DIR") {
|
||||||
|
dir if dir.is_empty() => default_attachments_dir(&database_path),
|
||||||
|
dir => PathBuf::from(dir),
|
||||||
|
};
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
address: fallback("BALANCE_ADDR", ":8080"),
|
address: fallback("BALANCE_ADDR", ":8080"),
|
||||||
database_path: fallback("BALANCE_DB_PATH", "./data/balance.db"),
|
database_path,
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
access_ttl,
|
access_ttl,
|
||||||
refresh_ttl,
|
refresh_ttl,
|
||||||
@@ -38,10 +47,19 @@ impl Config {
|
|||||||
.filter(|part| !part.is_empty())
|
.filter(|part| !part.is_empty())
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
attachments_dir,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `attachments` next to the database file.
|
||||||
|
pub fn default_attachments_dir(database_path: &str) -> PathBuf {
|
||||||
|
Path::new(database_path)
|
||||||
|
.parent()
|
||||||
|
.unwrap_or(Path::new(""))
|
||||||
|
.join("attachments")
|
||||||
|
}
|
||||||
|
|
||||||
fn value(key: &str) -> String {
|
fn value(key: &str) -> String {
|
||||||
env::var(key).unwrap_or_default().trim().to_owned()
|
env::var(key).unwrap_or_default().trim().to_owned()
|
||||||
}
|
}
|
||||||
@@ -128,6 +146,17 @@ pub fn parse_go_duration(input: &str) -> Option<TimeDelta> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
|
fn attachments_default_next_to_database() {
|
||||||
|
assert_eq!(
|
||||||
|
default_attachments_dir("/data/balance.db"),
|
||||||
|
Path::new("/data/attachments")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
default_attachments_dir("balance.db"),
|
||||||
|
Path::new("attachments")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn duration_matches_go_syntax_and_range() {
|
fn duration_matches_go_syntax_and_range() {
|
||||||
assert_eq!(parse_go_duration("1h30m"), Some(TimeDelta::minutes(90)));
|
assert_eq!(parse_go_duration("1h30m"), Some(TimeDelta::minutes(90)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+80
-12
@@ -1,7 +1,8 @@
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::{error::Error, path::Path, time::Duration};
|
use std::{error::Error, path::Path, time::Duration};
|
||||||
|
|
||||||
/// Open the existing Go server's SQLite database without changing its schema.
|
/// Open the SQLite database. Tables of the Go server's v1 schema are never
|
||||||
|
/// altered; contract v2 only adds new tables (ledgers, attachments).
|
||||||
pub fn open(path: &str) -> Result<Connection, Box<dyn Error + Send + Sync>> {
|
pub fn open(path: &str) -> Result<Connection, Box<dyn Error + Send + Sync>> {
|
||||||
if let Some(parent) = Path::new(path)
|
if let Some(parent) = Path::new(path)
|
||||||
.parent()
|
.parent()
|
||||||
@@ -63,7 +64,61 @@ CREATE TABLE IF NOT EXISTS changes (
|
|||||||
deleted INTEGER NOT NULL,
|
deleted INTEGER NOT NULL,
|
||||||
version INTEGER NOT NULL
|
version INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);",
|
CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);
|
||||||
|
CREATE TABLE IF NOT EXISTS ledgers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ledger_members (
|
||||||
|
ledger_id TEXT NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner','member')),
|
||||||
|
joined_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (ledger_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ledger_members_user_idx ON ledger_members(user_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS ledger_invites (
|
||||||
|
code_hash TEXT PRIMARY KEY,
|
||||||
|
ledger_id TEXT NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
used_by TEXT REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ledger_invites_ledger_idx ON ledger_invites(ledger_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS ledger_records (
|
||||||
|
ledger_id TEXT NOT NULL REFERENCES ledgers(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 (ledger_id, entity, record_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ledger_changes (
|
||||||
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ledger_id TEXT NOT NULL REFERENCES ledgers(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 ledger_changes_ledger_sequence_idx ON ledger_changes(ledger_id, sequence);
|
||||||
|
CREATE TABLE IF NOT EXISTS attachment_blobs (
|
||||||
|
space TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
uploaded_at TEXT NOT NULL,
|
||||||
|
referenced INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (space, sha256)
|
||||||
|
);",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,17 +169,10 @@ mod tests {
|
|||||||
let path = directory.join("existing.sqlite");
|
let path = directory.join("existing.sqlite");
|
||||||
{
|
{
|
||||||
let db = Connection::open(&path).unwrap();
|
let db = Connection::open(&path).unwrap();
|
||||||
// Use the original schema, so a divergence between implementations
|
// Use the original Go schema, so a divergence is caught even when
|
||||||
// is caught even when Rust's migration is internally consistent.
|
// Rust's migration is internally consistent.
|
||||||
let go_source = include_str!("../internal/database/database.go");
|
db.execute_batch(include_str!("../tests/fixtures/go_schema_v1.sql"))
|
||||||
let schema = go_source
|
|
||||||
.split("const schema = `")
|
|
||||||
.nth(1)
|
|
||||||
.unwrap()
|
|
||||||
.split('`')
|
|
||||||
.next()
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
db.execute_batch(schema).unwrap();
|
|
||||||
db.execute_batch(
|
db.execute_batch(
|
||||||
"INSERT INTO users VALUES ('go-user','go@example.com','existing hash','2026-01-01T00:00:00.123456789Z');
|
"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 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');
|
||||||
@@ -162,6 +210,26 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
"{\"amount\":12.5}"
|
"{\"amount\":12.5}"
|
||||||
);
|
);
|
||||||
|
// Contract v2 tables are added next to the untouched v1 tables.
|
||||||
|
for table in [
|
||||||
|
"ledgers",
|
||||||
|
"ledger_members",
|
||||||
|
"ledger_invites",
|
||||||
|
"ledger_records",
|
||||||
|
"ledger_changes",
|
||||||
|
"attachment_blobs",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
db.query_row(
|
||||||
|
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
[table],
|
||||||
|
|r| r.get::<_, i64>(0)
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
1,
|
||||||
|
"{table}"
|
||||||
|
);
|
||||||
|
}
|
||||||
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();
|
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.last_insert_rowid(), 42);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+365
@@ -0,0 +1,365 @@
|
|||||||
|
//! Shared ledgers (contract v2, section 7).
|
||||||
|
//!
|
||||||
|
//! A ledger is a data space with its own records, change stream and
|
||||||
|
//! attachments. Membership is checked for every request that selects it.
|
||||||
|
|
||||||
|
use crate::sync::Space;
|
||||||
|
use chrono::{SecondsFormat, TimeDelta, Utc};
|
||||||
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
pub type LedgerError = (u16, &'static str, &'static str);
|
||||||
|
|
||||||
|
pub const INVITE_TTL: TimeDelta = TimeDelta::days(7);
|
||||||
|
pub const CODE_LENGTH: usize = 10;
|
||||||
|
/// 32 symbols without look-alikes (no I, O, 0, 1): five random bits each.
|
||||||
|
const CODE_ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
const MAX_NAME: usize = 100;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Role {
|
||||||
|
Owner,
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Role {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Role::Owner => "owner",
|
||||||
|
Role::Member => "member",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal<E>(_: E) -> LedgerError {
|
||||||
|
(
|
||||||
|
500,
|
||||||
|
"internal_error",
|
||||||
|
"Could not process the ledger request",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn not_found() -> LedgerError {
|
||||||
|
(404, "not_found", "Ledger not found")
|
||||||
|
}
|
||||||
|
fn forbidden() -> LedgerError {
|
||||||
|
(403, "forbidden", "Not allowed for this ledger")
|
||||||
|
}
|
||||||
|
fn invalid_code() -> LedgerError {
|
||||||
|
(400, "invalid_code", "Invite code is invalid or expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> String {
|
||||||
|
Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Role of `user` in `ledger`: 404 for an unknown ledger, 403 for a non-member.
|
||||||
|
pub fn role(db: &Connection, ledger: &str, user: &str) -> Result<Role, LedgerError> {
|
||||||
|
let exists = db
|
||||||
|
.query_row("SELECT 1 FROM ledgers WHERE id=?", [ledger], |_| Ok(()))
|
||||||
|
.optional()
|
||||||
|
.map_err(internal)?
|
||||||
|
.is_some();
|
||||||
|
if !exists {
|
||||||
|
return Err(not_found());
|
||||||
|
}
|
||||||
|
let role: Option<String> = db
|
||||||
|
.query_row(
|
||||||
|
"SELECT role FROM ledger_members WHERE ledger_id=? AND user_id=?",
|
||||||
|
params![ledger, user],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(internal)?;
|
||||||
|
match role.as_deref() {
|
||||||
|
Some("owner") => Ok(Role::Owner),
|
||||||
|
Some(_) => Ok(Role::Member),
|
||||||
|
None => Err(forbidden()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data space selected by the `X-Balance-Ledger` header value.
|
||||||
|
pub fn space(db: &Connection, header: &str, user: &str) -> Result<Space, LedgerError> {
|
||||||
|
let ledger = header.trim();
|
||||||
|
if ledger.is_empty() {
|
||||||
|
return Ok(Space::User(user.to_owned()));
|
||||||
|
}
|
||||||
|
role(db, ledger, user)?;
|
||||||
|
Ok(Space::Ledger(ledger.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe(db: &Connection, ledger: &str, role: Role) -> Result<Value, LedgerError> {
|
||||||
|
let name: String = db
|
||||||
|
.query_row("SELECT name FROM ledgers WHERE id=?", [ledger], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.map_err(internal)?;
|
||||||
|
let mut stmt = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT m.user_id,u.email,m.role FROM ledger_members m JOIN users u ON u.id=m.user_id
|
||||||
|
WHERE m.ledger_id=? ORDER BY m.role='owner' DESC, m.joined_at, m.user_id",
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
let members = stmt
|
||||||
|
.query_map([ledger], |r| {
|
||||||
|
Ok(json!({"userId": r.get::<_, String>(0)?, "email": r.get::<_, String>(1)?, "role": r.get::<_, String>(2)?}))
|
||||||
|
})
|
||||||
|
.map_err(internal)?
|
||||||
|
.collect::<rusqlite::Result<Vec<Value>>>()
|
||||||
|
.map_err(internal)?;
|
||||||
|
Ok(json!({"id": ledger, "name": name, "role": role.as_str(), "members": members}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared ledgers of `user`; the personal space is not listed.
|
||||||
|
pub fn list(db: &Connection, user: &str) -> Result<Value, LedgerError> {
|
||||||
|
let mut stmt = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT l.id,m.role FROM ledger_members m JOIN ledgers l ON l.id=m.ledger_id
|
||||||
|
WHERE m.user_id=? ORDER BY l.created_at, l.id",
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([user], |r| {
|
||||||
|
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)? == "owner"))
|
||||||
|
})
|
||||||
|
.map_err(internal)?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(internal)?;
|
||||||
|
let ledgers = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, owner)| describe(db, &id, if owner { Role::Owner } else { Role::Member }))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(json!({ "ledgers": ledgers }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ledger name from a `{"name": …}` body: trimmed, 1–100 characters.
|
||||||
|
pub fn parse_name(body: &[u8]) -> Result<String, LedgerError> {
|
||||||
|
let value: Value = serde_json::from_slice(body)
|
||||||
|
.map_err(|_| (400, "invalid_json", "Request body is not valid JSON"))?;
|
||||||
|
let name = value
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("");
|
||||||
|
if name.is_empty() || name.chars().count() > MAX_NAME || name.chars().any(char::is_control) {
|
||||||
|
return Err((
|
||||||
|
400,
|
||||||
|
"invalid_name",
|
||||||
|
"Ledger name must contain 1 to 100 characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(name.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(db: &mut Connection, user: &str, name: &str) -> Result<Value, LedgerError> {
|
||||||
|
let id = uuid_v4().map_err(internal)?;
|
||||||
|
let tx = db.transaction().map_err(internal)?;
|
||||||
|
let created = now();
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO ledgers(id,name,owner_id,created_at) VALUES(?,?,?,?)",
|
||||||
|
params![id, name, user, created],
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO ledger_members(ledger_id,user_id,role,joined_at) VALUES(?,?,'owner',?)",
|
||||||
|
params![id, user, created],
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
let ledger = describe(&tx, &id, Role::Owner)?;
|
||||||
|
tx.commit().map_err(internal)?;
|
||||||
|
Ok(ledger)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename(db: &Connection, ledger: &str, user: &str, name: &str) -> Result<Value, LedgerError> {
|
||||||
|
if role(db, ledger, user)? != Role::Owner {
|
||||||
|
return Err(forbidden());
|
||||||
|
}
|
||||||
|
db.execute(
|
||||||
|
"UPDATE ledgers SET name=? WHERE id=?",
|
||||||
|
params![name, ledger],
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
describe(db, ledger, Role::Owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a ledger with its records, changes, invites and blob rows. The
|
||||||
|
/// caller removes the blob directory afterwards.
|
||||||
|
pub fn delete(db: &mut Connection, ledger: &str, user: &str) -> Result<(), LedgerError> {
|
||||||
|
if role(db, ledger, user)? != Role::Owner {
|
||||||
|
return Err(forbidden());
|
||||||
|
}
|
||||||
|
let tx = db.transaction().map_err(internal)?;
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM attachment_blobs WHERE space=?",
|
||||||
|
[Space::Ledger(ledger.to_owned()).key()],
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
// Cascades to members, invites, records and changes.
|
||||||
|
tx.execute("DELETE FROM ledgers WHERE id=?", [ledger])
|
||||||
|
.map_err(internal)?;
|
||||||
|
tx.commit().map_err(internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invite(db: &Connection, ledger: &str, user: &str) -> Result<Value, LedgerError> {
|
||||||
|
if role(db, ledger, user)? != Role::Owner {
|
||||||
|
return Err(forbidden());
|
||||||
|
}
|
||||||
|
let code = random_code().map_err(internal)?;
|
||||||
|
let created = Utc::now();
|
||||||
|
let expires = created + INVITE_TTL;
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO ledger_invites(code_hash,ledger_id,created_by,expires_at,created_at) VALUES(?,?,?,?,?)",
|
||||||
|
params![
|
||||||
|
crate::auth::token_hash(&code),
|
||||||
|
ledger,
|
||||||
|
user,
|
||||||
|
expires.to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||||
|
created.to_rfc3339_opts(SecondsFormat::Nanos, true)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
Ok(json!({
|
||||||
|
"code": code,
|
||||||
|
"expiresAt": expires.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical invite code: case-insensitive, spaces and dashes ignored.
|
||||||
|
pub fn normalize_code(code: &str) -> Option<String> {
|
||||||
|
let code: String = code
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_whitespace() && *c != '-')
|
||||||
|
.map(|c| c.to_ascii_uppercase())
|
||||||
|
.collect();
|
||||||
|
(code.len() == CODE_LENGTH && code.bytes().all(|b| CODE_ALPHABET.contains(&b))).then_some(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join(db: &mut Connection, user: &str, body: &[u8]) -> Result<Value, LedgerError> {
|
||||||
|
let value: Value = serde_json::from_slice(body)
|
||||||
|
.map_err(|_| (400, "invalid_json", "Request body is not valid JSON"))?;
|
||||||
|
let code = value
|
||||||
|
.get("code")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(normalize_code)
|
||||||
|
.ok_or_else(invalid_code)?;
|
||||||
|
let tx = db.transaction().map_err(internal)?;
|
||||||
|
let invite: Option<(String, String, Option<String>)> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT ledger_id,expires_at,used_at FROM ledger_invites WHERE code_hash=?",
|
||||||
|
[crate::auth::token_hash(&code)],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(internal)?;
|
||||||
|
let Some((ledger, expires, used)) = invite else {
|
||||||
|
return Err(invalid_code());
|
||||||
|
};
|
||||||
|
if used.is_some() || expires <= now() {
|
||||||
|
return Err(invalid_code());
|
||||||
|
}
|
||||||
|
// Joining a ledger one already belongs to does not consume the code.
|
||||||
|
if let Ok(existing) = role(&tx, &ledger, user) {
|
||||||
|
return describe(&tx, &ledger, existing);
|
||||||
|
}
|
||||||
|
let joined = now();
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE ledger_invites SET used_at=?, used_by=? WHERE code_hash=?",
|
||||||
|
params![joined, user, crate::auth::token_hash(&code)],
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO ledger_members(ledger_id,user_id,role,joined_at) VALUES(?,?,'member',?)",
|
||||||
|
params![ledger, user, joined],
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
let result = describe(&tx, &ledger, Role::Member)?;
|
||||||
|
tx.commit().map_err(internal)?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owner removes a member; a member removes only itself.
|
||||||
|
pub fn remove_member(
|
||||||
|
db: &Connection,
|
||||||
|
ledger: &str,
|
||||||
|
user: &str,
|
||||||
|
target: &str,
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
let caller = role(db, ledger, user)?;
|
||||||
|
if caller == Role::Member && target != user {
|
||||||
|
return Err(forbidden());
|
||||||
|
}
|
||||||
|
let target_role = match role(db, ledger, target) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err((403, _, _)) => return Err((404, "not_found", "Member not found")),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
if target_role == Role::Owner {
|
||||||
|
// Leaving is impossible while the ledger has an owner; the owner
|
||||||
|
// deletes the ledger instead.
|
||||||
|
return Err((
|
||||||
|
409,
|
||||||
|
"owner_cannot_leave",
|
||||||
|
"The owner cannot leave the ledger; delete it instead",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM ledger_members WHERE ledger_id=? AND user_id=?",
|
||||||
|
params![ledger, target],
|
||||||
|
)
|
||||||
|
.map_err(internal)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_code() -> Result<String, String> {
|
||||||
|
let mut bytes = [0u8; CODE_LENGTH];
|
||||||
|
getrandom(&mut bytes)?;
|
||||||
|
Ok(bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| CODE_ALPHABET[usize::from(b & 31)] as char)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uuid_v4() -> Result<String, String> {
|
||||||
|
let mut b = [0u8; 16];
|
||||||
|
getrandom(&mut b)?;
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40;
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80;
|
||||||
|
let hex: String = b.iter().map(|v| format!("{v:02x}")).collect();
|
||||||
|
Ok(format!(
|
||||||
|
"{}-{}-{}-{}-{}",
|
||||||
|
&hex[..8],
|
||||||
|
&hex[8..12],
|
||||||
|
&hex[12..16],
|
||||||
|
&hex[16..20],
|
||||||
|
&hex[20..]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getrandom(bytes: &mut [u8]) -> Result<(), String> {
|
||||||
|
use rand::RngCore;
|
||||||
|
rand::rngs::OsRng
|
||||||
|
.try_fill_bytes(bytes)
|
||||||
|
.map_err(|_| "random source unavailable".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codes_are_ten_unambiguous_symbols_and_normalize() {
|
||||||
|
let code = random_code().unwrap();
|
||||||
|
assert_eq!(code.len(), CODE_LENGTH);
|
||||||
|
assert_eq!(normalize_code(&code), Some(code.clone()));
|
||||||
|
let lower = format!(
|
||||||
|
" {}-{} ",
|
||||||
|
code[..5].to_lowercase(),
|
||||||
|
code[5..].to_lowercase()
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_code(&lower), Some(code));
|
||||||
|
assert_eq!(normalize_code("ABCDEFGHI0"), None);
|
||||||
|
assert_eq!(normalize_code("ABCDEFGHJ"), None);
|
||||||
|
let id = uuid_v4().unwrap();
|
||||||
|
assert_eq!(id.len(), 36);
|
||||||
|
assert_eq!(&id[14..15], "4");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
mod attachments;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod database;
|
mod database;
|
||||||
mod go_json;
|
mod go_json;
|
||||||
|
mod ledgers;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod server;
|
mod server;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
|||||||
+302
-36
@@ -1,4 +1,4 @@
|
|||||||
use crate::{auth, config::Config, sync};
|
use crate::{attachments, auth, config::Config, ledgers, sync};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::{Body, HttpBody},
|
body::{Body, HttpBody},
|
||||||
extract::{ConnectInfo, State as ExtractState},
|
extract::{ConnectInfo, State as ExtractState},
|
||||||
@@ -24,12 +24,14 @@ pub struct State {
|
|||||||
cfg: Config,
|
cfg: Config,
|
||||||
db: Mutex<Connection>,
|
db: Mutex<Connection>,
|
||||||
tokens: auth::TokenManager,
|
tokens: auth::TokenManager,
|
||||||
|
attachments: attachments::Store,
|
||||||
clients: Mutex<HashMap<String, (Instant, u64)>>,
|
clients: Mutex<HashMap<String, (Instant, u64)>>,
|
||||||
}
|
}
|
||||||
impl State {
|
impl State {
|
||||||
pub fn new(cfg: Config, db: Mutex<Connection>) -> Self {
|
pub fn new(cfg: Config, db: Mutex<Connection>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
tokens: auth::TokenManager::new(&cfg.jwt_secret, cfg.access_ttl),
|
tokens: auth::TokenManager::new(&cfg.jwt_secret, cfg.access_ttl),
|
||||||
|
attachments: attachments::Store::new(cfg.attachments_dir.clone()),
|
||||||
cfg,
|
cfg,
|
||||||
db,
|
db,
|
||||||
clients: Mutex::new(HashMap::new()),
|
clients: Mutex::new(HashMap::new()),
|
||||||
@@ -62,8 +64,17 @@ pub async fn handle(
|
|||||||
};
|
};
|
||||||
redirect(&location, &method)
|
redirect(&location, &method)
|
||||||
} else {
|
} else {
|
||||||
// Bound memory independently of Content-Length, including chunked requests.
|
// Bound memory independently of Content-Length, including chunked
|
||||||
let body = read_body(body).await;
|
// requests. Only an authorized attachment upload may exceed 2 MiB.
|
||||||
|
let limit = if method == Method::PUT
|
||||||
|
&& percent_decode(&path).starts_with("/v1/attachments/")
|
||||||
|
&& authorize(&state, &headers).is_ok()
|
||||||
|
{
|
||||||
|
attachments::MAX_BYTES + 1
|
||||||
|
} else {
|
||||||
|
2 << 20
|
||||||
|
};
|
||||||
|
let body = read_body(body, limit).await;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let headers = headers.clone();
|
let headers = headers.clone();
|
||||||
let escaped_slash = path.to_ascii_lowercase().contains("%2f");
|
let escaped_slash = path.to_ascii_lowercase().contains("%2f");
|
||||||
@@ -134,13 +145,25 @@ pub async fn handle(
|
|||||||
h.insert("Access-Control-Allow-Origin", value);
|
h.insert("Access-Control-Allow-Origin", value);
|
||||||
}
|
}
|
||||||
h.insert("Vary", HeaderValue::from_static("Origin"));
|
h.insert("Vary", HeaderValue::from_static("Origin"));
|
||||||
|
// v1 responses keep the recorded Go headers; contract v2 routes and
|
||||||
|
// real preflights (which v1 browsers also send) get the superset.
|
||||||
|
let extended = is_v2_route(&percent_decode(&path))
|
||||||
|
|| headers.contains_key("access-control-request-method");
|
||||||
h.insert(
|
h.insert(
|
||||||
"Access-Control-Allow-Headers",
|
"Access-Control-Allow-Headers",
|
||||||
HeaderValue::from_static("Authorization, Content-Type"),
|
HeaderValue::from_static(if extended {
|
||||||
|
"Authorization, Content-Type, X-Balance-Ledger"
|
||||||
|
} else {
|
||||||
|
"Authorization, Content-Type"
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
h.insert(
|
h.insert(
|
||||||
"Access-Control-Allow-Methods",
|
"Access-Control-Allow-Methods",
|
||||||
HeaderValue::from_static("GET, POST, OPTIONS"),
|
HeaderValue::from_static(if extended {
|
||||||
|
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||||
|
} else {
|
||||||
|
"GET, POST, OPTIONS"
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if is_head {
|
if is_head {
|
||||||
@@ -151,16 +174,16 @@ pub async fn handle(
|
|||||||
|
|
||||||
// Go's decoder consumes one value, so a valid first value may be followed by
|
// Go's decoder consumes one value, so a valid first value may be followed by
|
||||||
// ignored data beyond MaxBytesReader's cap. Keep the bounded prefix available.
|
// ignored data beyond MaxBytesReader's cap. Keep the bounded prefix available.
|
||||||
async fn read_body(mut body: Body) -> Result<Vec<u8>, ()> {
|
async fn read_body(mut body: Body, limit: usize) -> Result<Vec<u8>, ()> {
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
while bytes.len() < 2 << 20 {
|
while bytes.len() < limit {
|
||||||
let frame = std::future::poll_fn(|cx| std::pin::Pin::new(&mut body).poll_frame(cx)).await;
|
let frame = std::future::poll_fn(|cx| std::pin::Pin::new(&mut body).poll_frame(cx)).await;
|
||||||
let Some(frame) = frame else {
|
let Some(frame) = frame else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let frame = frame.map_err(|_| ())?;
|
let frame = frame.map_err(|_| ())?;
|
||||||
if let Ok(data) = frame.into_data() {
|
if let Ok(data) = frame.into_data() {
|
||||||
let remaining = (2 << 20) - bytes.len();
|
let remaining = limit - bytes.len();
|
||||||
bytes.extend_from_slice(&data[..data.len().min(remaining)]);
|
bytes.extend_from_slice(&data[..data.len().min(remaining)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +198,9 @@ fn dispatch(
|
|||||||
body: Result<&[u8], &()>,
|
body: Result<&[u8], &()>,
|
||||||
host: &str,
|
host: &str,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
if let Some(response) = dispatch_v2(state, method, path, headers, body, host) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
let post_route = matches!(
|
let post_route = matches!(
|
||||||
path,
|
path,
|
||||||
"/v1/auth/register"
|
"/v1/auth/register"
|
||||||
@@ -208,21 +234,10 @@ fn dispatch(
|
|||||||
if matches!(
|
if matches!(
|
||||||
path,
|
path,
|
||||||
"/v1/auth/register" | "/v1/auth/login" | "/v1/auth/refresh"
|
"/v1/auth/register" | "/v1/auth/login" | "/v1/auth/refresh"
|
||||||
) {
|
) && let Some(r) = rate_limited(state, host)
|
||||||
let now = Instant::now();
|
{
|
||||||
let mut clients = state.clients.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let bucket = clients.entry(host.to_owned()).or_insert((now, 0));
|
|
||||||
if now.duration_since(bucket.0) >= Duration::from_secs(60) {
|
|
||||||
*bucket = (now, 0);
|
|
||||||
}
|
|
||||||
bucket.1 = bucket.1.saturating_add(1);
|
|
||||||
if bucket.1 > 30 {
|
|
||||||
let mut r = error(429, "rate_limited", "Too many authentication requests");
|
|
||||||
r.headers_mut()
|
|
||||||
.insert("Retry-After", HeaderValue::from_static("60"));
|
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let claims = if path == "/v1/sync" {
|
let claims = if path == "/v1/sync" {
|
||||||
match authorize(state, headers) {
|
match authorize(state, headers) {
|
||||||
Ok(c) => Some(c),
|
Ok(c) => Some(c),
|
||||||
@@ -238,12 +253,17 @@ fn dispatch(
|
|||||||
return invalid_json();
|
return invalid_json();
|
||||||
};
|
};
|
||||||
if let Some(claims) = claims {
|
if let Some(claims) = claims {
|
||||||
return match sync::synchronize_wire(
|
let mut db = state.db.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
&mut state.db.lock().unwrap_or_else(|e| e.into_inner()),
|
let space = match ledgers::space(&db, header(headers, LEDGER_HEADER), &claims.subject) {
|
||||||
&claims.subject,
|
Ok(space) => space,
|
||||||
body,
|
Err((status, code, message)) => return error(status, code, message),
|
||||||
) {
|
};
|
||||||
Ok(value) => raw_json_response(200, value),
|
return match sync::synchronize_space(&mut db, &space, body) {
|
||||||
|
Ok((value, garbage)) => {
|
||||||
|
// Still under the database lock, so no upload can race.
|
||||||
|
state.attachments.remove(&space, &garbage);
|
||||||
|
raw_json_response(200, value)
|
||||||
|
}
|
||||||
Err((status, code, message)) => error(status, code, &message),
|
Err((status, code, message)) => error(status, code, &message),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -280,6 +300,248 @@ fn dispatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LEDGER_HEADER: &str = "x-balance-ledger";
|
||||||
|
|
||||||
|
/// Shared limiter of endpoints that accept guessable secrets.
|
||||||
|
fn rate_limited(state: &State, host: &str) -> Option<Response> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut clients = state.clients.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let bucket = clients.entry(host.to_owned()).or_insert((now, 0));
|
||||||
|
if now.duration_since(bucket.0) >= Duration::from_secs(60) {
|
||||||
|
*bucket = (now, 0);
|
||||||
|
}
|
||||||
|
bucket.1 = bucket.1.saturating_add(1);
|
||||||
|
if bucket.1 > 30 {
|
||||||
|
let mut r = error(429, "rate_limited", "Too many authentication requests");
|
||||||
|
r.headers_mut()
|
||||||
|
.insert("Retry-After", HeaderValue::from_static("60"));
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Route<'a> {
|
||||||
|
Capabilities,
|
||||||
|
Ledgers,
|
||||||
|
Join,
|
||||||
|
Ledger(&'a str),
|
||||||
|
Invites(&'a str),
|
||||||
|
Member(&'a str, &'a str),
|
||||||
|
Attachment(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route(path: &str) -> Option<Route<'_>> {
|
||||||
|
let rest = path.strip_prefix("/v1/")?;
|
||||||
|
let segments: Vec<&str> = rest.split('/').collect();
|
||||||
|
if segments.iter().any(|s| s.is_empty()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(match segments.as_slice() {
|
||||||
|
["capabilities"] => Route::Capabilities,
|
||||||
|
["ledgers"] => Route::Ledgers,
|
||||||
|
["ledgers", "join"] => Route::Join,
|
||||||
|
["ledgers", id] => Route::Ledger(id),
|
||||||
|
["ledgers", id, "invites"] => Route::Invites(id),
|
||||||
|
["ledgers", id, "members", user] => Route::Member(id, user),
|
||||||
|
["attachments", sha] => Route::Attachment(sha),
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_v2_route(path: &str) -> bool {
|
||||||
|
route(path).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_not_allowed(allow: &'static str) -> Response {
|
||||||
|
let mut r = plain(405, "Method Not Allowed\n");
|
||||||
|
r.headers_mut()
|
||||||
|
.insert("Allow", HeaderValue::from_static(allow));
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ledger_error((status, code, message): ledgers::LedgerError) -> Response {
|
||||||
|
error(status, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract v2 endpoints. `None` hands the request to the v1 dispatcher.
|
||||||
|
fn dispatch_v2(
|
||||||
|
state: &State,
|
||||||
|
method: &Method,
|
||||||
|
path: &str,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
body: Result<&[u8], &()>,
|
||||||
|
host: &str,
|
||||||
|
) -> Option<Response> {
|
||||||
|
let route = route(path)?;
|
||||||
|
let get = method == Method::GET || method == Method::HEAD;
|
||||||
|
let allowed = match route {
|
||||||
|
Route::Capabilities => get,
|
||||||
|
Route::Ledgers => get || method == Method::POST,
|
||||||
|
Route::Join | Route::Invites(_) => method == Method::POST,
|
||||||
|
Route::Ledger(_) => method == Method::PATCH || method == Method::DELETE,
|
||||||
|
Route::Member(..) => method == Method::DELETE,
|
||||||
|
Route::Attachment(_) => get || method == Method::PUT,
|
||||||
|
};
|
||||||
|
if !allowed {
|
||||||
|
return Some(method_not_allowed(match route {
|
||||||
|
Route::Capabilities => "GET, HEAD",
|
||||||
|
Route::Ledgers => "GET, HEAD, POST",
|
||||||
|
Route::Join | Route::Invites(_) => "POST",
|
||||||
|
Route::Ledger(_) => "PATCH, DELETE",
|
||||||
|
Route::Member(..) => "DELETE",
|
||||||
|
Route::Attachment(_) => "GET, HEAD, PUT",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Route::Capabilities = route {
|
||||||
|
return Some(json_response(200, capabilities()));
|
||||||
|
}
|
||||||
|
if let Route::Join = route
|
||||||
|
&& let Some(r) = rate_limited(state, host)
|
||||||
|
{
|
||||||
|
return Some(r);
|
||||||
|
}
|
||||||
|
let claims = match authorize(state, headers) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(r) => return Some(*r),
|
||||||
|
};
|
||||||
|
let user = claims.subject.as_str();
|
||||||
|
let Ok(body) = body else {
|
||||||
|
return Some(invalid_json());
|
||||||
|
};
|
||||||
|
let lock = || state.db.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
Some(match route {
|
||||||
|
Route::Capabilities => unreachable!("answered above"),
|
||||||
|
Route::Ledgers if get => match ledgers::list(&lock(), user) {
|
||||||
|
Ok(v) => json_response(200, v),
|
||||||
|
Err(e) => ledger_error(e),
|
||||||
|
},
|
||||||
|
Route::Ledgers => match ledgers::parse_name(body)
|
||||||
|
.and_then(|name| ledgers::create(&mut lock(), user, &name))
|
||||||
|
{
|
||||||
|
Ok(v) => json_response(201, v),
|
||||||
|
Err(e) => ledger_error(e),
|
||||||
|
},
|
||||||
|
Route::Join => match ledgers::join(&mut lock(), user, body) {
|
||||||
|
Ok(v) => json_response(200, v),
|
||||||
|
Err(e) => ledger_error(e),
|
||||||
|
},
|
||||||
|
Route::Ledger(id) if method == Method::PATCH => {
|
||||||
|
let db = lock();
|
||||||
|
match ledgers::role(&db, id, user)
|
||||||
|
.and_then(|_| ledgers::parse_name(body))
|
||||||
|
.and_then(|name| ledgers::rename(&db, id, user, &name))
|
||||||
|
{
|
||||||
|
Ok(v) => json_response(200, v),
|
||||||
|
Err(e) => ledger_error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route::Ledger(id) => {
|
||||||
|
let mut db = lock();
|
||||||
|
match ledgers::delete(&mut db, id, user) {
|
||||||
|
Ok(()) => {
|
||||||
|
// Still under the lock: no upload can recreate the directory.
|
||||||
|
state
|
||||||
|
.attachments
|
||||||
|
.remove_space(&sync::Space::Ledger(id.to_owned()));
|
||||||
|
empty(204)
|
||||||
|
}
|
||||||
|
Err(e) => ledger_error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route::Invites(id) => match ledgers::invite(&lock(), id, user) {
|
||||||
|
Ok(v) => json_response(201, v),
|
||||||
|
Err(e) => ledger_error(e),
|
||||||
|
},
|
||||||
|
Route::Member(id, target) => match ledgers::remove_member(&lock(), id, user, target) {
|
||||||
|
Ok(()) => empty(204),
|
||||||
|
Err(e) => ledger_error(e),
|
||||||
|
},
|
||||||
|
Route::Attachment(sha) => {
|
||||||
|
let space = match ledgers::space(&lock(), header(headers, LEDGER_HEADER), user) {
|
||||||
|
Ok(space) => space,
|
||||||
|
Err(e) => return Some(ledger_error(e)),
|
||||||
|
};
|
||||||
|
if get {
|
||||||
|
get_attachment(state, &space, sha)
|
||||||
|
} else {
|
||||||
|
put_attachment(state, &space, sha, header(headers, "content-type"), body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities() -> Value {
|
||||||
|
json!({
|
||||||
|
"contract": 2,
|
||||||
|
"entities": sync::ENTITIES,
|
||||||
|
"attachments": {"maxBytes": attachments::MAX_BYTES, "types": attachments::TYPES},
|
||||||
|
"ledgers": true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_attachment(
|
||||||
|
state: &State,
|
||||||
|
space: &sync::Space,
|
||||||
|
sha: &str,
|
||||||
|
content_type: &str,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Response {
|
||||||
|
use attachments::PutError;
|
||||||
|
match attachments::put(
|
||||||
|
&state.db,
|
||||||
|
&state.attachments,
|
||||||
|
space,
|
||||||
|
sha,
|
||||||
|
content_type,
|
||||||
|
body,
|
||||||
|
) {
|
||||||
|
Ok(created) => json_response(
|
||||||
|
if created { 201 } else { 200 },
|
||||||
|
json!({"sha256": sha, "size": body.len(), "contentType": attachments::media_type(content_type)}),
|
||||||
|
),
|
||||||
|
Err(PutError::HashMismatch) => error(
|
||||||
|
400,
|
||||||
|
"hash_mismatch",
|
||||||
|
"SHA-256 of the body does not match the path",
|
||||||
|
),
|
||||||
|
Err(PutError::UnsupportedMediaType) => error(
|
||||||
|
415,
|
||||||
|
"unsupported_media_type",
|
||||||
|
"Content-Type is not an accepted attachment type",
|
||||||
|
),
|
||||||
|
Err(PutError::PayloadTooLarge) => error(
|
||||||
|
413,
|
||||||
|
"payload_too_large",
|
||||||
|
"Attachment exceeds the maximum size",
|
||||||
|
),
|
||||||
|
Err(PutError::Internal) => error(500, "internal_error", "Could not store attachment"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_attachment(state: &State, space: &sync::Space, sha: &str) -> Response {
|
||||||
|
match attachments::get(&state.db, &state.attachments, space, sha) {
|
||||||
|
Ok(Some((bytes, media))) => {
|
||||||
|
let mut r = empty(200);
|
||||||
|
if let Ok(v) = HeaderValue::from_str(&media) {
|
||||||
|
r.headers_mut().insert("Content-Type", v);
|
||||||
|
}
|
||||||
|
r.headers_mut().insert(
|
||||||
|
"Content-Length",
|
||||||
|
HeaderValue::from_str(&bytes.len().to_string()).unwrap(),
|
||||||
|
);
|
||||||
|
// Never render user files inline as part of the web client origin.
|
||||||
|
r.headers_mut().insert(
|
||||||
|
"Content-Disposition",
|
||||||
|
HeaderValue::from_static("attachment"),
|
||||||
|
);
|
||||||
|
*r.body_mut() = Body::from(bytes);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
Ok(None) => error(404, "not_found", "Attachment not found"),
|
||||||
|
Err(()) => error(500, "internal_error", "Could not read attachment"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Input {
|
struct Input {
|
||||||
email: String,
|
email: String,
|
||||||
@@ -687,27 +949,24 @@ fn web(path: &str, headers: &HeaderMap) -> Response {
|
|||||||
}
|
}
|
||||||
let (bytes, mime): (&'static [u8], &'static str) = match cleaned.as_str() {
|
let (bytes, mime): (&'static [u8], &'static str) = match cleaned.as_str() {
|
||||||
"/" => (
|
"/" => (
|
||||||
include_bytes!("../internal/httpapi/web/index.html"),
|
include_bytes!("../web/index.html"),
|
||||||
"text/html; charset=utf-8",
|
"text/html; charset=utf-8",
|
||||||
),
|
),
|
||||||
"/app.js" => (
|
"/app.js" => (
|
||||||
include_bytes!("../internal/httpapi/web/app.js"),
|
include_bytes!("../web/app.js"),
|
||||||
"text/javascript; charset=utf-8",
|
"text/javascript; charset=utf-8",
|
||||||
),
|
),
|
||||||
"/service-worker.js" => (
|
"/service-worker.js" => (
|
||||||
include_bytes!("../internal/httpapi/web/service-worker.js"),
|
include_bytes!("../web/service-worker.js"),
|
||||||
"text/javascript; charset=utf-8",
|
"text/javascript; charset=utf-8",
|
||||||
),
|
),
|
||||||
"/styles.css" => (
|
"/styles.css" => (
|
||||||
include_bytes!("../internal/httpapi/web/styles.css"),
|
include_bytes!("../web/styles.css"),
|
||||||
"text/css; charset=utf-8",
|
"text/css; charset=utf-8",
|
||||||
),
|
),
|
||||||
"/icon.svg" => (
|
"/icon.svg" => (include_bytes!("../web/icon.svg"), "image/svg+xml"),
|
||||||
include_bytes!("../internal/httpapi/web/icon.svg"),
|
|
||||||
"image/svg+xml",
|
|
||||||
),
|
|
||||||
"/manifest.webmanifest" => (
|
"/manifest.webmanifest" => (
|
||||||
include_bytes!("../internal/httpapi/web/manifest.webmanifest"),
|
include_bytes!("../web/manifest.webmanifest"),
|
||||||
"text/plain; charset=utf-8",
|
"text/plain; charset=utf-8",
|
||||||
),
|
),
|
||||||
_ => return plain(404, "404 page not found\n"),
|
_ => return plain(404, "404 page not found\n"),
|
||||||
@@ -871,6 +1130,10 @@ mod tests {
|
|||||||
refresh_ttl: chrono::TimeDelta::days(30),
|
refresh_ttl: chrono::TimeDelta::days(30),
|
||||||
allow_registration: true,
|
allow_registration: true,
|
||||||
cors_origins: vec!["https://allowed.example".into()],
|
cors_origins: vec!["https://allowed.example".into()],
|
||||||
|
attachments_dir: std::env::temp_dir().join(format!(
|
||||||
|
"balance-attachments-{}",
|
||||||
|
auth::identifier().unwrap()
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
Mutex::new(crate::database::open(":memory:").unwrap()),
|
Mutex::new(crate::database::open(":memory:").unwrap()),
|
||||||
)
|
)
|
||||||
@@ -1013,3 +1276,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod v2_tests;
|
||||||
@@ -0,0 +1,892 @@
|
|||||||
|
//! Endpoint tests for contract v2: capabilities, ledgers and attachments.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
struct Env {
|
||||||
|
state: State,
|
||||||
|
dir: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Env {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env() -> Env {
|
||||||
|
let dir = std::env::temp_dir().join(format!("balance-v2-test-{}", auth::identifier().unwrap()));
|
||||||
|
let state = State::new(
|
||||||
|
Config {
|
||||||
|
address: "127.0.0.1:0".into(),
|
||||||
|
database_path: ":memory:".into(),
|
||||||
|
jwt_secret: "test secret".into(),
|
||||||
|
access_ttl: chrono::TimeDelta::minutes(15),
|
||||||
|
refresh_ttl: chrono::TimeDelta::days(30),
|
||||||
|
allow_registration: true,
|
||||||
|
cors_origins: vec!["https://allowed.example".into()],
|
||||||
|
attachments_dir: dir.clone(),
|
||||||
|
},
|
||||||
|
Mutex::new(crate::database::open(":memory:").unwrap()),
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let db = state.db.lock().unwrap();
|
||||||
|
for user in ["owner", "member", "stranger"] {
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO users VALUES(?,?,'hash','now')",
|
||||||
|
params![user, format!("{user}@example.com")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Env { state, dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_headers(env: &Env, user: &str, ledger: Option<&str>) -> HeaderMap {
|
||||||
|
let (token, _) = env
|
||||||
|
.state
|
||||||
|
.tokens
|
||||||
|
.access_token(user, &format!("{user}@example.com"))
|
||||||
|
.unwrap();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"Authorization",
|
||||||
|
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||||
|
);
|
||||||
|
if let Some(ledger) = ledger {
|
||||||
|
headers.insert("X-Balance-Ledger", HeaderValue::from_str(ledger).unwrap());
|
||||||
|
}
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(
|
||||||
|
env: &Env,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
user: Option<&str>,
|
||||||
|
ledger: Option<&str>,
|
||||||
|
body: &[u8],
|
||||||
|
) -> (u16, Value) {
|
||||||
|
let headers = match user {
|
||||||
|
Some(user) => auth_headers(env, user, ledger),
|
||||||
|
None => HeaderMap::new(),
|
||||||
|
};
|
||||||
|
request(env, method, path, &headers, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(env: &Env, method: &str, path: &str, headers: &HeaderMap, body: &[u8]) -> (u16, Value) {
|
||||||
|
let r = dispatch(
|
||||||
|
&env.state,
|
||||||
|
&Method::from_bytes(method.as_bytes()).unwrap(),
|
||||||
|
path,
|
||||||
|
headers,
|
||||||
|
Ok(body),
|
||||||
|
"test-host",
|
||||||
|
);
|
||||||
|
let status = r.status().as_u16();
|
||||||
|
let bytes = futures_body(r);
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn futures_body(r: Response) -> Vec<u8> {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(axum::body::to_bytes(r.into_body(), usize::MAX))
|
||||||
|
.unwrap()
|
||||||
|
.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_of(v: &Value) -> &str {
|
||||||
|
v["error"]["code"].as_str().unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_ledger(env: &Env) -> String {
|
||||||
|
let (status, v) = call(
|
||||||
|
env,
|
||||||
|
"POST",
|
||||||
|
"/v1/ledgers",
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
br#"{"name":" Family "}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(status, 201);
|
||||||
|
v["id"].as_str().unwrap().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_member(env: &Env, ledger: &str) {
|
||||||
|
let (_, invite) = call(
|
||||||
|
env,
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/ledgers/{ledger}/invites"),
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
);
|
||||||
|
let body = json!({"code": invite["code"]}).to_string();
|
||||||
|
let (status, _) = call(
|
||||||
|
env,
|
||||||
|
"POST",
|
||||||
|
"/v1/ledgers/join",
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
body.as_bytes(),
|
||||||
|
);
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha(bytes: &[u8]) -> String {
|
||||||
|
Sha256::digest(bytes)
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(
|
||||||
|
env: &Env,
|
||||||
|
user: &str,
|
||||||
|
ledger: Option<&str>,
|
||||||
|
hash: &str,
|
||||||
|
ty: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> (u16, Value) {
|
||||||
|
let mut headers = auth_headers(env, user, ledger);
|
||||||
|
headers.insert("Content-Type", HeaderValue::from_str(ty).unwrap());
|
||||||
|
request(
|
||||||
|
env,
|
||||||
|
"PUT",
|
||||||
|
&format!("/v1/attachments/{hash}"),
|
||||||
|
&headers,
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_raw(env: &Env, user: &str, ledger: Option<&str>, hash: &str) -> Response {
|
||||||
|
dispatch(
|
||||||
|
&env.state,
|
||||||
|
&Method::GET,
|
||||||
|
&format!("/v1/attachments/{hash}"),
|
||||||
|
&auth_headers(env, user, ledger),
|
||||||
|
Ok(b""),
|
||||||
|
"test-host",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_call(env: &Env, user: &str, ledger: Option<&str>, body: Value) -> (u16, Value) {
|
||||||
|
call(
|
||||||
|
env,
|
||||||
|
"POST",
|
||||||
|
"/v1/sync",
|
||||||
|
Some(user),
|
||||||
|
ledger,
|
||||||
|
body.to_string().as_bytes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_change(id: &str, hash: &str, time: &str, deleted: bool) -> Value {
|
||||||
|
json!({"entity":"attachment","id":id,"updatedAt":time,"deleted":deleted,
|
||||||
|
"payload":{"transactionId":"t","sha256":hash,"contentType":"image/png","size":3,"name":"a.png","createdAt":time}})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capabilities_are_public_and_match_contract() {
|
||||||
|
let env = env();
|
||||||
|
let (status, v) = call(&env, "GET", "/v1/capabilities", None, None, b"");
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert_eq!(
|
||||||
|
v,
|
||||||
|
json!({
|
||||||
|
"contract": 2,
|
||||||
|
"entities": ["transaction","category","budget","account","transfer",
|
||||||
|
"recurring","goal","debt","rate","setting","attachment"],
|
||||||
|
"attachments": {"maxBytes": 10485760,
|
||||||
|
"types": ["image/jpeg","image/png","image/heic","application/pdf"]},
|
||||||
|
"ledgers": true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let r = dispatch(
|
||||||
|
&env.state,
|
||||||
|
&Method::POST,
|
||||||
|
"/v1/capabilities",
|
||||||
|
&HeaderMap::new(),
|
||||||
|
Ok(b""),
|
||||||
|
"h",
|
||||||
|
);
|
||||||
|
assert_eq!(r.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||||
|
assert_eq!(r.headers()["Allow"], "GET, HEAD");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_accepts_every_v2_entity_and_still_rejects_unknown_ones() {
|
||||||
|
let env = env();
|
||||||
|
let changes: Vec<Value> = sync::ENTITIES
|
||||||
|
.iter()
|
||||||
|
.map(|e| json!({"entity":e,"id":format!("{e}-1"),"updatedAt":"2026-01-01T00:00:00Z","payload":{"x":1}}))
|
||||||
|
.collect();
|
||||||
|
let (status, v) = sync_call(&env, "owner", None, json!({ "changes": changes }));
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert_eq!(v["changes"].as_array().unwrap().len(), 11);
|
||||||
|
let (status, v) = sync_call(
|
||||||
|
&env,
|
||||||
|
"owner",
|
||||||
|
None,
|
||||||
|
json!({"changes":[{"entity":"wallet","id":"w","updatedAt":"2026-01-01T00:00:00Z","payload":{}}]}),
|
||||||
|
);
|
||||||
|
assert_eq!(status, 400);
|
||||||
|
assert_eq!(code_of(&v), "invalid_change");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v2_endpoints_require_authorization() {
|
||||||
|
let env = env();
|
||||||
|
for (method, path) in [
|
||||||
|
("GET", "/v1/ledgers"),
|
||||||
|
("POST", "/v1/ledgers"),
|
||||||
|
("POST", "/v1/ledgers/join"),
|
||||||
|
("PATCH", "/v1/ledgers/x"),
|
||||||
|
("DELETE", "/v1/ledgers/x"),
|
||||||
|
("POST", "/v1/ledgers/x/invites"),
|
||||||
|
("DELETE", "/v1/ledgers/x/members/y"),
|
||||||
|
("GET", "/v1/attachments/x"),
|
||||||
|
("PUT", "/v1/attachments/x"),
|
||||||
|
] {
|
||||||
|
let (status, v) = call(&env, method, path, None, None, b"{}");
|
||||||
|
assert_eq!(status, 401, "{method} {path}");
|
||||||
|
assert_eq!(code_of(&v), "authorization_required");
|
||||||
|
}
|
||||||
|
let (status, _) = call(&env, "PUT", "/v1/ledgers", Some("owner"), None, b"");
|
||||||
|
assert_eq!(status, 405);
|
||||||
|
let (status, _) = call(&env, "GET", "/v1/ledgers/join", Some("owner"), None, b"");
|
||||||
|
assert_eq!(status, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ledger_lifecycle_roles_and_error_codes() {
|
||||||
|
let env = env();
|
||||||
|
// Personal space is not listed.
|
||||||
|
let (status, v) = call(&env, "GET", "/v1/ledgers", Some("owner"), None, b"");
|
||||||
|
assert_eq!((status, v), (200, json!({"ledgers": []})));
|
||||||
|
for bad in [&br#"{"name":" "}"#[..], br#"{}"#] {
|
||||||
|
let (status, v) = call(&env, "POST", "/v1/ledgers", Some("owner"), None, bad);
|
||||||
|
assert_eq!((status, code_of(&v)), (400, "invalid_name"));
|
||||||
|
}
|
||||||
|
let (status, v) = call(&env, "POST", "/v1/ledgers", Some("owner"), None, b"{");
|
||||||
|
assert_eq!((status, code_of(&v)), (400, "invalid_json"));
|
||||||
|
|
||||||
|
let id = create_ledger(&env);
|
||||||
|
let (_, v) = call(&env, "GET", "/v1/ledgers", Some("owner"), None, b"");
|
||||||
|
assert_eq!(
|
||||||
|
v,
|
||||||
|
json!({"ledgers":[{"id":id,"name":"Family","role":"owner",
|
||||||
|
"members":[{"userId":"owner","email":"owner@example.com","role":"owner"}]}]})
|
||||||
|
);
|
||||||
|
let ledger = format!("/v1/ledgers/{id}");
|
||||||
|
let invites = format!("{ledger}/invites");
|
||||||
|
|
||||||
|
// Unknown ledger → 404, non-member → 403.
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"PATCH",
|
||||||
|
"/v1/ledgers/missing",
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
br#"{"name":"x"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (404, "not_found"));
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"PATCH",
|
||||||
|
&ledger,
|
||||||
|
Some("stranger"),
|
||||||
|
None,
|
||||||
|
br#"{"name":"x"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (403, "forbidden"));
|
||||||
|
let (status, v) = call(&env, "POST", &invites, Some("stranger"), None, b"");
|
||||||
|
assert_eq!((status, code_of(&v)), (403, "forbidden"));
|
||||||
|
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"PATCH",
|
||||||
|
&ledger,
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
br#"{"name":"Home"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!((status, v["name"].as_str()), (200, Some("Home")));
|
||||||
|
|
||||||
|
// Invites: 10 characters, 7 days, hashed at rest, single use.
|
||||||
|
let (status, invite) = call(&env, "POST", &invites, Some("owner"), None, b"");
|
||||||
|
assert_eq!(status, 201);
|
||||||
|
let code = invite["code"].as_str().unwrap().to_owned();
|
||||||
|
assert_eq!(code.len(), 10);
|
||||||
|
let expires = DateTime::parse_from_rfc3339(invite["expiresAt"].as_str().unwrap()).unwrap();
|
||||||
|
let days = (expires.with_timezone(&Utc) - Utc::now()).num_hours();
|
||||||
|
assert!((167..=168).contains(&days), "{days}");
|
||||||
|
{
|
||||||
|
let db = env.state.db.lock().unwrap();
|
||||||
|
let stored: String = db
|
||||||
|
.query_row("SELECT code_hash FROM ledger_invites", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(stored, code);
|
||||||
|
assert_eq!(stored, auth::token_hash(&code));
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
json!({"code":"AAAAAAAAAA"}),
|
||||||
|
json!({"code":"short"}),
|
||||||
|
json!({}),
|
||||||
|
] {
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"POST",
|
||||||
|
"/v1/ledgers/join",
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
bad.to_string().as_bytes(),
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (400, "invalid_code"));
|
||||||
|
}
|
||||||
|
let lower = json!({"code": code.to_lowercase()}).to_string();
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"POST",
|
||||||
|
"/v1/ledgers/join",
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
lower.as_bytes(),
|
||||||
|
);
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert_eq!(v["role"], "member");
|
||||||
|
assert_eq!(v["members"].as_array().unwrap().len(), 2);
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"POST",
|
||||||
|
"/v1/ledgers/join",
|
||||||
|
Some("stranger"),
|
||||||
|
None,
|
||||||
|
lower.as_bytes(),
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (400, "invalid_code"));
|
||||||
|
|
||||||
|
// Expired codes are rejected.
|
||||||
|
let (_, expired) = call(&env, "POST", &invites, Some("owner"), None, b"");
|
||||||
|
env.state
|
||||||
|
.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute(
|
||||||
|
"UPDATE ledger_invites SET expires_at='2000-01-01T00:00:00.000000000Z' WHERE code_hash=?",
|
||||||
|
[auth::token_hash(expired["code"].as_str().unwrap())],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let body = json!({"code": expired["code"]}).to_string();
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"POST",
|
||||||
|
"/v1/ledgers/join",
|
||||||
|
Some("stranger"),
|
||||||
|
None,
|
||||||
|
body.as_bytes(),
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (400, "invalid_code"));
|
||||||
|
|
||||||
|
// Members may not administer the ledger.
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"PATCH",
|
||||||
|
&ledger,
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
br#"{"name":"x"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (403, "forbidden"));
|
||||||
|
let (status, v) = call(&env, "POST", &invites, Some("member"), None, b"");
|
||||||
|
assert_eq!((status, code_of(&v)), (403, "forbidden"));
|
||||||
|
let (status, v) = call(&env, "DELETE", &ledger, Some("member"), None, b"");
|
||||||
|
assert_eq!((status, code_of(&v)), (403, "forbidden"));
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"DELETE",
|
||||||
|
&format!("{ledger}/members/owner"),
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (403, "forbidden"));
|
||||||
|
let (_, v) = call(&env, "GET", "/v1/ledgers", Some("member"), None, b"");
|
||||||
|
assert_eq!(v["ledgers"][0]["role"], "member");
|
||||||
|
|
||||||
|
// The owner cannot leave; unknown members are 404.
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"DELETE",
|
||||||
|
&format!("{ledger}/members/owner"),
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (409, "owner_cannot_leave"));
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"DELETE",
|
||||||
|
&format!("{ledger}/members/stranger"),
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (404, "not_found"));
|
||||||
|
|
||||||
|
// A member leaves; the owner removes a member.
|
||||||
|
let (status, _) = call(
|
||||||
|
&env,
|
||||||
|
"DELETE",
|
||||||
|
&format!("{ledger}/members/member"),
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
);
|
||||||
|
assert_eq!(status, 204);
|
||||||
|
let (_, v) = call(&env, "GET", "/v1/ledgers", Some("member"), None, b"");
|
||||||
|
assert_eq!(v, json!({"ledgers": []}));
|
||||||
|
join_member(&env, &id);
|
||||||
|
let (status, _) = call(
|
||||||
|
&env,
|
||||||
|
"DELETE",
|
||||||
|
&format!("{ledger}/members/member"),
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
);
|
||||||
|
assert_eq!(status, 204);
|
||||||
|
let (status, v) = sync_call(&env, "member", Some(&id), json!({}));
|
||||||
|
assert_eq!((status, code_of(&v)), (403, "forbidden"));
|
||||||
|
|
||||||
|
let (status, _) = call(&env, "DELETE", &ledger, Some("owner"), None, b"");
|
||||||
|
assert_eq!(status, 204);
|
||||||
|
let (status, v) = call(&env, "DELETE", &ledger, Some("owner"), None, b"");
|
||||||
|
assert_eq!((status, code_of(&v)), (404, "not_found"));
|
||||||
|
let db = env.state.db.lock().unwrap();
|
||||||
|
for table in ["ledgers", "ledger_members", "ledger_invites"] {
|
||||||
|
let n: i64 = db
|
||||||
|
.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 0, "{table}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn joining_twice_keeps_the_code_and_the_role() {
|
||||||
|
let env = env();
|
||||||
|
let id = create_ledger(&env);
|
||||||
|
let (_, invite) = call(
|
||||||
|
&env,
|
||||||
|
"POST",
|
||||||
|
&format!("/v1/ledgers/{id}/invites"),
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
);
|
||||||
|
let body = json!({"code": invite["code"]}).to_string();
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"POST",
|
||||||
|
"/v1/ledgers/join",
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
body.as_bytes(),
|
||||||
|
);
|
||||||
|
assert_eq!((status, v["role"].as_str()), (200, Some("owner")));
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"POST",
|
||||||
|
"/v1/ledgers/join",
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
body.as_bytes(),
|
||||||
|
);
|
||||||
|
assert_eq!((status, v["role"].as_str()), (200, Some("member")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_attempts_are_rate_limited() {
|
||||||
|
let env = env();
|
||||||
|
for _ in 0..30 {
|
||||||
|
let (status, _) = call(
|
||||||
|
&env,
|
||||||
|
"POST",
|
||||||
|
"/v1/ledgers/join",
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
br#"{"code":"x"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(status, 400);
|
||||||
|
}
|
||||||
|
let (status, v) = call(
|
||||||
|
&env,
|
||||||
|
"POST",
|
||||||
|
"/v1/ledgers/join",
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
br#"{"code":"x"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (429, "rate_limited"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ledger_sync_is_shared_isolated_and_has_its_own_cursor() {
|
||||||
|
let env = env();
|
||||||
|
// Personal data first, so personal and ledger sequences differ.
|
||||||
|
let personal = json!({"changes":[{"entity":"category","id":"p","updatedAt":"2026-01-01T00:00:00Z","payload":{"name":"Mine"}}]});
|
||||||
|
let (_, v) = sync_call(&env, "owner", None, personal.clone());
|
||||||
|
assert_eq!(v["cursor"], 1);
|
||||||
|
let (_, v) = sync_call(&env, "owner", None, personal);
|
||||||
|
assert_eq!(v["cursor"], 1);
|
||||||
|
|
||||||
|
let id = create_ledger(&env);
|
||||||
|
join_member(&env, &id);
|
||||||
|
let shared = json!({"changes":[{"entity":"transaction","id":"t","updatedAt":"2026-01-02T00:00:00Z","payload":{"amount":10}}]});
|
||||||
|
let (status, v) = sync_call(&env, "owner", Some(&id), shared);
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert_eq!(v["cursor"], 1);
|
||||||
|
assert_eq!(v["changes"][0]["id"], "t");
|
||||||
|
|
||||||
|
// The member sees the ledger record and nothing personal of the owner.
|
||||||
|
let (_, v) = sync_call(&env, "member", Some(&id), json!({}));
|
||||||
|
assert_eq!(v["changes"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["changes"][0]["entity"], "transaction");
|
||||||
|
// Last write wins among members.
|
||||||
|
let edit = json!({"cursor":1,"changes":[{"entity":"transaction","id":"t","updatedAt":"2026-01-03T00:00:00Z","payload":{"amount":20}}]});
|
||||||
|
let (_, v) = sync_call(&env, "member", Some(&id), edit);
|
||||||
|
assert_eq!(v["changes"][0]["version"], 2);
|
||||||
|
assert_eq!(v["cursor"], 2);
|
||||||
|
let (_, v) = sync_call(&env, "owner", Some(&id), json!({"cursor":1}));
|
||||||
|
assert_eq!(v["changes"][0]["payload"]["amount"], 20);
|
||||||
|
|
||||||
|
// Personal spaces stay untouched.
|
||||||
|
let (_, v) = sync_call(&env, "member", None, json!({}));
|
||||||
|
assert_eq!(
|
||||||
|
(v["cursor"].clone(), v["changes"].clone()),
|
||||||
|
(json!(0), json!([]))
|
||||||
|
);
|
||||||
|
let (_, v) = sync_call(&env, "owner", None, json!({}));
|
||||||
|
assert_eq!(v["changes"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["changes"][0]["id"], "p");
|
||||||
|
// An empty header means the personal space.
|
||||||
|
let (_, v) = sync_call(&env, "owner", Some(" "), json!({}));
|
||||||
|
assert_eq!(v["changes"][0]["id"], "p");
|
||||||
|
|
||||||
|
let (status, v) = sync_call(&env, "stranger", Some(&id), json!({}));
|
||||||
|
assert_eq!((status, code_of(&v)), (403, "forbidden"));
|
||||||
|
let (status, v) = sync_call(&env, "owner", Some("missing"), json!({}));
|
||||||
|
assert_eq!((status, code_of(&v)), (404, "not_found"));
|
||||||
|
|
||||||
|
// Deleting the ledger removes its records and changes.
|
||||||
|
call(
|
||||||
|
&env,
|
||||||
|
"DELETE",
|
||||||
|
&format!("/v1/ledgers/{id}"),
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
);
|
||||||
|
let db = env.state.db.lock().unwrap();
|
||||||
|
for table in ["ledger_records", "ledger_changes"] {
|
||||||
|
let n: i64 = db
|
||||||
|
.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 0, "{table}");
|
||||||
|
}
|
||||||
|
let n: i64 = db
|
||||||
|
.query_row("SELECT count(*) FROM records", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_upload_validation_and_idempotency() {
|
||||||
|
let env = env();
|
||||||
|
let bytes = b"\x89PNG fake";
|
||||||
|
let hash = sha(bytes);
|
||||||
|
let (status, v) = put(&env, "owner", None, &hash, "image/png", bytes);
|
||||||
|
assert_eq!(status, 201);
|
||||||
|
assert_eq!(v["sha256"], hash.as_str());
|
||||||
|
let (status, _) = put(&env, "owner", None, &hash, "image/png", bytes);
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let (status, v) = put(&env, "owner", None, &sha(b"other"), "image/png", bytes);
|
||||||
|
assert_eq!((status, code_of(&v)), (400, "hash_mismatch"));
|
||||||
|
let (status, v) = put(
|
||||||
|
&env,
|
||||||
|
"owner",
|
||||||
|
None,
|
||||||
|
&hash.to_uppercase(),
|
||||||
|
"image/png",
|
||||||
|
bytes,
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (400, "hash_mismatch"));
|
||||||
|
let (status, v) = put(&env, "owner", None, &hash, "image/gif", bytes);
|
||||||
|
assert_eq!((status, code_of(&v)), (415, "unsupported_media_type"));
|
||||||
|
let mut headers = auth_headers(&env, "owner", None);
|
||||||
|
let (status, v) = request(
|
||||||
|
&env,
|
||||||
|
"PUT",
|
||||||
|
&format!("/v1/attachments/{hash}"),
|
||||||
|
&headers,
|
||||||
|
bytes,
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (415, "unsupported_media_type"));
|
||||||
|
headers.insert("Content-Type", HeaderValue::from_static("application/pdf"));
|
||||||
|
let large = vec![0u8; attachments::MAX_BYTES + 1];
|
||||||
|
let (status, v) = request(
|
||||||
|
&env,
|
||||||
|
"PUT",
|
||||||
|
&format!("/v1/attachments/{}", sha(&large)),
|
||||||
|
&headers,
|
||||||
|
&large,
|
||||||
|
);
|
||||||
|
assert_eq!((status, code_of(&v)), (413, "payload_too_large"));
|
||||||
|
let max = vec![1u8; attachments::MAX_BYTES];
|
||||||
|
let (status, _) = request(
|
||||||
|
&env,
|
||||||
|
"PUT",
|
||||||
|
&format!("/v1/attachments/{}", sha(&max)),
|
||||||
|
&headers,
|
||||||
|
&max,
|
||||||
|
);
|
||||||
|
assert_eq!(status, 201);
|
||||||
|
|
||||||
|
let r = get_raw(&env, "owner", None, &hash);
|
||||||
|
assert_eq!(r.status(), StatusCode::OK);
|
||||||
|
assert_eq!(r.headers()["Content-Type"], "image/png");
|
||||||
|
assert_eq!(r.headers()["Content-Disposition"], "attachment");
|
||||||
|
assert_eq!(futures_body(r), bytes);
|
||||||
|
// Other users' spaces do not see the file.
|
||||||
|
assert_eq!(
|
||||||
|
get_raw(&env, "member", None, &hash).status(),
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_raw(&env, "owner", None, "nothex").status(),
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachments_are_per_ledger_and_check_membership() {
|
||||||
|
let env = env();
|
||||||
|
let id = create_ledger(&env);
|
||||||
|
join_member(&env, &id);
|
||||||
|
let bytes = b"%PDF-1.7 receipt";
|
||||||
|
let hash = sha(bytes);
|
||||||
|
assert_eq!(
|
||||||
|
put(&env, "member", Some(&id), &hash, "application/pdf", bytes).0,
|
||||||
|
201
|
||||||
|
);
|
||||||
|
let r = get_raw(&env, "owner", Some(&id), &hash);
|
||||||
|
assert_eq!(r.status(), StatusCode::OK);
|
||||||
|
assert_eq!(futures_body(r), bytes);
|
||||||
|
// The same file in the personal space is stored separately.
|
||||||
|
assert_eq!(
|
||||||
|
get_raw(&env, "owner", None, &hash).status(),
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
put(&env, "owner", None, &hash, "application/pdf", bytes).0,
|
||||||
|
201
|
||||||
|
);
|
||||||
|
|
||||||
|
let (status, v) = put(&env, "stranger", Some(&id), &hash, "application/pdf", bytes);
|
||||||
|
assert_eq!((status, code_of(&v)), (403, "forbidden"));
|
||||||
|
assert_eq!(
|
||||||
|
get_raw(&env, "stranger", Some(&id), &hash).status(),
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_raw(&env, "owner", Some("missing"), &hash).status(),
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ledger deletion removes its blobs, the personal copy stays.
|
||||||
|
let ledger_dir = env.dir.join("ledgers").join(&id);
|
||||||
|
assert!(ledger_dir.is_dir());
|
||||||
|
call(
|
||||||
|
&env,
|
||||||
|
"DELETE",
|
||||||
|
&format!("/v1/ledgers/{id}"),
|
||||||
|
Some("owner"),
|
||||||
|
None,
|
||||||
|
b"",
|
||||||
|
);
|
||||||
|
assert!(!ledger_dir.exists());
|
||||||
|
assert_eq!(get_raw(&env, "owner", None, &hash).status(), StatusCode::OK);
|
||||||
|
let n: i64 = env
|
||||||
|
.state
|
||||||
|
.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.query_row("SELECT count(*) FROM attachment_blobs", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blobs_are_collected_when_no_live_record_references_them() {
|
||||||
|
let env = env();
|
||||||
|
let bytes = b"jpeg bytes";
|
||||||
|
let hash = sha(bytes);
|
||||||
|
let pending = b"uploaded, not yet synced";
|
||||||
|
let pending_hash = sha(pending);
|
||||||
|
assert_eq!(put(&env, "owner", None, &hash, "image/jpeg", bytes).0, 201);
|
||||||
|
assert_eq!(
|
||||||
|
put(&env, "owner", None, &pending_hash, "image/jpeg", pending).0,
|
||||||
|
201
|
||||||
|
);
|
||||||
|
let file = env
|
||||||
|
.dir
|
||||||
|
.join("users")
|
||||||
|
.join("owner")
|
||||||
|
.join(&hash[..2])
|
||||||
|
.join(&hash);
|
||||||
|
assert!(file.is_file());
|
||||||
|
|
||||||
|
let (status, _) = sync_call(
|
||||||
|
&env,
|
||||||
|
"owner",
|
||||||
|
None,
|
||||||
|
json!({"changes":[attachment_change("a1", &hash, "2026-01-01T00:00:00Z", false),
|
||||||
|
attachment_change("a2", &hash, "2026-01-01T00:00:00Z", false)]}),
|
||||||
|
);
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
// One of two referencing records is deleted: the blob stays.
|
||||||
|
sync_call(
|
||||||
|
&env,
|
||||||
|
"owner",
|
||||||
|
None,
|
||||||
|
json!({"changes":[attachment_change("a1", &hash, "2026-01-02T00:00:00Z", true)]}),
|
||||||
|
);
|
||||||
|
assert_eq!(get_raw(&env, "owner", None, &hash).status(), StatusCode::OK);
|
||||||
|
// The last one is deleted: the blob is collected.
|
||||||
|
sync_call(
|
||||||
|
&env,
|
||||||
|
"owner",
|
||||||
|
None,
|
||||||
|
json!({"changes":[attachment_change("a2", &hash, "2026-01-02T00:00:00Z", true)]}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_raw(&env, "owner", None, &hash).status(),
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
assert!(!file.exists());
|
||||||
|
// A fresh upload whose record is not synced yet survives the grace period…
|
||||||
|
assert_eq!(
|
||||||
|
get_raw(&env, "owner", None, &pending_hash).status(),
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
// …but is collected once it is older than the grace period.
|
||||||
|
env.state
|
||||||
|
.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute(
|
||||||
|
"UPDATE attachment_blobs SET uploaded_at='2000-01-01T00:00:00.000000000Z'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
sync_call(
|
||||||
|
&env,
|
||||||
|
"owner",
|
||||||
|
None,
|
||||||
|
json!({"changes":[attachment_change("a3", &hash, "2026-01-03T00:00:00Z", true)]}),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get_raw(&env, "owner", None, &pending_hash).status(),
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
// Re-uploading a collected blob creates it again.
|
||||||
|
assert_eq!(put(&env, "owner", None, &hash, "image/jpeg", bytes).0, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn large_uploads_pass_the_body_limit_only_when_authorized() {
|
||||||
|
let env = env();
|
||||||
|
let state = Arc::new(State::new(
|
||||||
|
env.state.cfg.clone(),
|
||||||
|
Mutex::new(crate::database::open(":memory:").unwrap()),
|
||||||
|
));
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO users VALUES('owner','owner@example.com','hash','now')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (token, _) = state
|
||||||
|
.tokens
|
||||||
|
.access_token("owner", "owner@example.com")
|
||||||
|
.unwrap();
|
||||||
|
let bytes = vec![7u8; (2 << 20) + 10];
|
||||||
|
let hash = sha(&bytes);
|
||||||
|
let r = handle(
|
||||||
|
ExtractState(state.clone()),
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri(format!("/v1/attachments/{hash}"))
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.header("Content-Type", "image/heic")
|
||||||
|
.body(Body::from(bytes.clone()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), StatusCode::CREATED);
|
||||||
|
assert_eq!(r.headers()["Cache-Control"], "no-store");
|
||||||
|
let r = handle(
|
||||||
|
ExtractState(state.clone()),
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri(format!("/v1/attachments/{hash}"))
|
||||||
|
.header("Content-Type", "image/heic")
|
||||||
|
.body(Body::from(bytes))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cors_preflights_allow_v2_methods_and_ledger_header() {
|
||||||
|
let env = env();
|
||||||
|
let state = Arc::new(State::new(
|
||||||
|
env.state.cfg.clone(),
|
||||||
|
Mutex::new(crate::database::open(":memory:").unwrap()),
|
||||||
|
));
|
||||||
|
for (uri, request_method) in [("/v1/ledgers/x", None), ("/v1/sync", Some("POST"))] {
|
||||||
|
let mut builder = Request::builder()
|
||||||
|
.method("OPTIONS")
|
||||||
|
.uri(uri)
|
||||||
|
.header("Origin", "https://allowed.example");
|
||||||
|
if let Some(m) = request_method {
|
||||||
|
builder = builder
|
||||||
|
.header("Access-Control-Request-Method", m)
|
||||||
|
.header("Access-Control-Request-Headers", "x-balance-ledger");
|
||||||
|
}
|
||||||
|
let r = handle(
|
||||||
|
ExtractState(state.clone()),
|
||||||
|
builder.body(Body::empty()).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(r.status(), StatusCode::NO_CONTENT);
|
||||||
|
assert_eq!(
|
||||||
|
r.headers()["Access-Control-Allow-Headers"],
|
||||||
|
"Authorization, Content-Type, X-Balance-Ledger"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.headers()["Access-Control-Allow-Methods"],
|
||||||
|
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-11
@@ -259,11 +259,68 @@ fn failure(status: u16, code: &'static str, message: &str) -> SyncError {
|
|||||||
(status, code, message.to_owned())
|
(status, code, message.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Entities accepted by `/v1/sync` (contract v2, section 1).
|
||||||
|
pub const ENTITIES: [&str; 11] = [
|
||||||
|
"transaction",
|
||||||
|
"category",
|
||||||
|
"budget",
|
||||||
|
"account",
|
||||||
|
"transfer",
|
||||||
|
"recurring",
|
||||||
|
"goal",
|
||||||
|
"debt",
|
||||||
|
"rate",
|
||||||
|
"setting",
|
||||||
|
"attachment",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A data space: the user's personal records (v1 tables) or a shared ledger.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Space {
|
||||||
|
User(String),
|
||||||
|
Ledger(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Space {
|
||||||
|
/// Records table, changes table and the column that selects the space.
|
||||||
|
pub fn tables(&self) -> (&'static str, &'static str, &'static str) {
|
||||||
|
match self {
|
||||||
|
Space::User(_) => ("records", "changes", "user_id"),
|
||||||
|
Space::Ledger(_) => ("ledger_records", "ledger_changes", "ledger_id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Space::User(id) | Space::Ledger(id) => id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Stable key of the space in `attachment_blobs`.
|
||||||
|
pub fn key(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Space::User(id) => format!("user:{id}"),
|
||||||
|
Space::Ledger(id) => format!("ledger:{id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronize the personal space of `user_id`.
|
||||||
|
#[cfg(test)]
|
||||||
pub fn synchronize_wire(
|
pub fn synchronize_wire(
|
||||||
db: &mut Connection,
|
db: &mut Connection,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
) -> Result<Vec<u8>, SyncError> {
|
) -> Result<Vec<u8>, SyncError> {
|
||||||
|
synchronize_space(db, &Space::User(user_id.to_owned()), body).map(|(response, _)| response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a sync request in `space`. Besides the response body, it returns the
|
||||||
|
/// attachment blobs that became garbage; their rows are already deleted in
|
||||||
|
/// the committed transaction and the caller removes the files.
|
||||||
|
pub fn synchronize_space(
|
||||||
|
db: &mut Connection,
|
||||||
|
space: &Space,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<(Vec<u8>, Vec<String>), SyncError> {
|
||||||
let invalid_json = || failure(400, "invalid_json", "Request body is not valid JSON");
|
let invalid_json = || failure(400, "invalid_json", "Request body is not valid JSON");
|
||||||
let body = &body[..body.len().min(2 << 20)];
|
let body = &body[..body.len().min(2 << 20)];
|
||||||
// The original decoder reads the first JSON value, without requiring EOF.
|
// The original decoder reads the first JSON value, without requiring EOF.
|
||||||
@@ -287,7 +344,9 @@ pub fn synchronize_wire(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
for c in &request.changes {
|
for c in &request.changes {
|
||||||
let message = if !matches!(c.entity.as_str(), "transaction" | "category" | "budget") {
|
let message = if !ENTITIES.contains(&c.entity.as_str()) {
|
||||||
|
// The message is part of the recorded v1 reference responses
|
||||||
|
// (tools/compatibility_expected.txt), so it is kept unchanged.
|
||||||
Some("entity must be transaction, category, or budget")
|
Some("entity must be transaction, category, or budget")
|
||||||
} else if c.id.is_empty() || c.id.len() > 128 {
|
} else if c.id.is_empty() || c.id.len() > 128 {
|
||||||
Some("record id is invalid")
|
Some("record id is invalid")
|
||||||
@@ -313,12 +372,18 @@ pub fn synchronize_wire(
|
|||||||
let tx = db
|
let tx = db
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|_| failure(500, "sync_failed", "Could not begin synchronization"))?;
|
.map_err(|_| failure(500, "sync_failed", "Could not begin synchronization"))?;
|
||||||
|
let touches_attachments = request.changes.iter().any(|c| c.entity == "attachment");
|
||||||
for c in request.changes {
|
for c in request.changes {
|
||||||
apply(&tx, user_id, c)
|
apply(&tx, space, c).map_err(|_| failure(500, "sync_failed", "Could not apply changes"))?;
|
||||||
.map_err(|_| failure(500, "sync_failed", "Could not apply changes"))?;
|
|
||||||
}
|
}
|
||||||
|
let garbage = if touches_attachments {
|
||||||
|
crate::attachments::collect_garbage(&tx, space, Utc::now())
|
||||||
|
.map_err(|_| failure(500, "sync_failed", "Could not apply changes"))?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
let (changes, cursor, more) = if request.pull.unwrap_or(true) {
|
let (changes, cursor, more) = if request.pull.unwrap_or(true) {
|
||||||
load(&tx, user_id, request.cursor)
|
load(&tx, space, request.cursor)
|
||||||
.map_err(|_| failure(500, "sync_failed", "Could not load changes"))?
|
.map_err(|_| failure(500, "sync_failed", "Could not load changes"))?
|
||||||
} else {
|
} else {
|
||||||
(Vec::new(), request.cursor, false)
|
(Vec::new(), request.cursor, false)
|
||||||
@@ -339,7 +404,7 @@ pub fn synchronize_wire(
|
|||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
Ok(response)
|
Ok((response, garbage))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -351,10 +416,12 @@ fn synchronize(db: &mut Connection, user_id: &str, body: &[u8]) -> Result<Value,
|
|||||||
|
|
||||||
type InternalError = Box<dyn std::error::Error + Send + Sync>;
|
type InternalError = Box<dyn std::error::Error + Send + Sync>;
|
||||||
|
|
||||||
fn apply(tx: &Transaction<'_>, user: &str, c: Change) -> Result<(), InternalError> {
|
fn apply(tx: &Transaction<'_>, space: &Space, c: Change) -> Result<(), InternalError> {
|
||||||
|
let (records, changes, key) = space.tables();
|
||||||
|
let user = space.id();
|
||||||
let current: Option<(String, i64)> = tx
|
let current: Option<(String, i64)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT updated_at,version FROM records WHERE user_id=? AND entity=? AND record_id=?",
|
&format!("SELECT updated_at,version FROM {records} WHERE {key}=? AND entity=? AND record_id=?"),
|
||||||
params![user, c.entity, c.id],
|
params![user, c.entity, c.id],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)
|
)
|
||||||
@@ -373,17 +440,19 @@ fn apply(tx: &Transaction<'_>, user: &str, c: Change) -> Result<(), InternalErro
|
|||||||
c.payload.as_deref()
|
c.payload.as_deref()
|
||||||
};
|
};
|
||||||
let time = timestamp(updated);
|
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(&format!("INSERT INTO {records}({key},entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,CAST(? AS TEXT),?,?,?) ON CONFLICT({key},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])?;
|
tx.execute(&format!("INSERT INTO {changes}({key},entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,CAST(? AS TEXT),?,?,?)"), params![user,c.entity,c.id,payload,time,c.deleted,version])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load(
|
fn load(
|
||||||
tx: &Transaction<'_>,
|
tx: &Transaction<'_>,
|
||||||
user: &str,
|
space: &Space,
|
||||||
after: i64,
|
after: i64,
|
||||||
) -> Result<(Vec<Vec<u8>>, i64, bool), InternalError> {
|
) -> 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 (_, changes, key) = space.tables();
|
||||||
|
let user = space.id();
|
||||||
|
let mut stmt = tx.prepare(&format!("SELECT sequence,entity,record_id,payload,updated_at,deleted,version FROM {changes} WHERE {key}=? AND sequence>? ORDER BY sequence ASC LIMIT ?"))?;
|
||||||
let mut rows = stmt.query(params![user, after, 501])?;
|
let mut rows = stmt.query(params![user, after, 501])?;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut cursor = after;
|
let mut cursor = after;
|
||||||
|
|||||||
@@ -1,43 +1,5 @@
|
|||||||
package database
|
-- SQLite schema created by the original Go server (v1.0.0, internal/database/database.go).
|
||||||
|
-- Rust must keep opening databases with exactly this schema; do not edit.
|
||||||
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 (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
@@ -74,7 +36,3 @@ CREATE TABLE IF NOT EXISTS changes (
|
|||||||
version INTEGER NOT NULL
|
version INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);
|
CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence);
|
||||||
`
|
|
||||||
_, err := db.ExecContext(ctx, schema)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
+51
-28
@@ -1,6 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Black-box Go/Rust compatibility and bilateral SQLite takeover checks (stdlib only)."""
|
"""Black-box HTTP/JSON regression checks against the recorded Go reference (stdlib only).
|
||||||
|
|
||||||
|
compatibility_expected.txt holds the observable responses of the original Go
|
||||||
|
server (v1.0.0), which the Apple and Android clients were built against. Any
|
||||||
|
difference is a client-visible API change. After an intentional change, review
|
||||||
|
the diff and re-record with --update.
|
||||||
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import ast
|
||||||
import base64
|
import base64
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import contextlib
|
import contextlib
|
||||||
@@ -9,6 +16,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pprint
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import socket
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -17,6 +25,7 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
EXPECTED = Path(__file__).with_name('compatibility_expected.txt')
|
||||||
SECRET = 'compatibility-test-secret-not-for-deployment-0123456789'
|
SECRET = 'compatibility-test-secret-not-for-deployment-0123456789'
|
||||||
PASSWORD = 'compatibility password'
|
PASSWORD = 'compatibility password'
|
||||||
|
|
||||||
@@ -370,38 +379,52 @@ def raw_json_scenarios(binary, database):
|
|||||||
return observed
|
return observed
|
||||||
|
|
||||||
|
|
||||||
|
def web_assets(value):
|
||||||
|
"""Stand in for embedded web files, so the reference survives web client edits."""
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
for asset in sorted((ROOT / 'web').iterdir()):
|
||||||
|
if value == asset.read_bytes():
|
||||||
|
return ('web asset', asset.name)
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {k: web_assets(v) for k, v in value.items()}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return type(value)(web_assets(v) for v in value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('--go-binary', type=Path)
|
parser.add_argument('--binary', type=Path, default=ROOT / 'target/debug/balance-server')
|
||||||
parser.add_argument('--rust-binary', type=Path, default=ROOT / 'target/debug/balance-server')
|
parser.add_argument('--update', action='store_true',
|
||||||
|
help=f'record the responses as the new reference in {EXPECTED.name}')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
binary = args.binary.resolve()
|
||||||
|
assert binary.is_file(), f'Build first: cargo build --locked (missing {binary})'
|
||||||
with tempfile.TemporaryDirectory(prefix='balance-compatibility-') as directory:
|
with tempfile.TemporaryDirectory(prefix='balance-compatibility-') as directory:
|
||||||
directory = Path(directory)
|
directory = Path(directory)
|
||||||
go = args.go_binary.resolve() if args.go_binary else directory / 'go-server'
|
actual = {}
|
||||||
if args.go_binary is None:
|
print('Running HTTP/JSON scenarios...', flush=True)
|
||||||
subprocess.run(['go','build','-o',str(go),'./cmd/balance-server'], cwd=ROOT, check=True)
|
actual['scenario'] = scenario(binary, directory / 'scenario.db')
|
||||||
rust = args.rust_binary.resolve()
|
print('Running HTTP/JSON edge cases...', flush=True)
|
||||||
assert rust.is_file(), f'Build Rust first: cargo build --locked (missing {rust})'
|
actual['edge'] = edge_scenarios(binary, directory / 'edge.db')
|
||||||
print('Running Go reference scenarios...', flush=True)
|
print('Running raw JSON payload and Unicode preservation...', flush=True)
|
||||||
expected = scenario(go, directory / 'go.db')
|
actual['raw'] = raw_json_scenarios(binary, directory / 'raw.db')
|
||||||
print('Running Rust scenarios...', flush=True)
|
actual = web_assets(actual)
|
||||||
actual = scenario(rust, directory / 'rust.db')
|
if args.update:
|
||||||
for key in expected:
|
EXPECTED.write_text(pprint.pformat(actual, width=100) + '\n')
|
||||||
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}'
|
print(f'Recorded {EXPECTED.relative_to(ROOT)}')
|
||||||
print('Comparing HTTP/JSON edge cases...', flush=True)
|
else:
|
||||||
expected = edge_scenarios(go, directory / 'go-edge.db')
|
expected = ast.literal_eval(EXPECTED.read_text())
|
||||||
actual = edge_scenarios(rust, directory / 'rust-edge.db')
|
for group in expected:
|
||||||
for key in expected:
|
for key in expected[group]:
|
||||||
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}'
|
assert actual[group].get(key) == expected[group][key], (
|
||||||
print('Comparing raw JSON payload and Unicode preservation...', flush=True)
|
f'Reference mismatch: {group} {key}\n'
|
||||||
expected = raw_json_scenarios(go, directory / 'go-raw.db')
|
f'Expected: {expected[group][key]!r}\nActual: {actual[group].get(key)!r}')
|
||||||
actual = raw_json_scenarios(rust, directory / 'rust-raw.db')
|
assert actual[group].keys() == expected[group].keys(), f'Scenario set changed in {group}; re-record with --update'
|
||||||
for key in expected:
|
print('Checking SQLite persistence across restarts...', flush=True)
|
||||||
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}'
|
takeover(binary, binary, directory / 'restart.db')
|
||||||
for first, second, name in [(go,rust,'go-rust'),(rust,go,'rust-go')]:
|
print('PASS: HTTP/JSON reference scenarios and SQLite persistence')
|
||||||
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -0,0 +1,769 @@
|
|||||||
|
{'edge': {'expired refresh': (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_refresh_token',
|
||||||
|
'message': 'Refresh token is invalid or expired'}}),
|
||||||
|
('DELETE', '/app.js'): (405,
|
||||||
|
{'allow': 'GET, HEAD',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('DELETE', '/health'): (405,
|
||||||
|
{'allow': 'GET, HEAD',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('DELETE', '/v1/auth/login'): (405,
|
||||||
|
{'allow': 'GET, HEAD, POST',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('DELETE', '/v1/me'): (405,
|
||||||
|
{'allow': 'GET, HEAD',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('DELETE', '/v1/sync'): (405,
|
||||||
|
{'allow': 'GET, HEAD, POST',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('HEAD', '/app.js'): (200, {'content-type': 'text/javascript; charset=utf-8'}, b''),
|
||||||
|
('HEAD', '/health'): (200, {'content-type': 'application/json; charset=utf-8'}, b''),
|
||||||
|
('HEAD', '/v1/auth/login'): (404,
|
||||||
|
{'content-type': 'application/json; charset=utf-8'},
|
||||||
|
b''),
|
||||||
|
('HEAD', '/v1/me'): (401, {'content-type': 'application/json; charset=utf-8'}, b''),
|
||||||
|
('HEAD', '/v1/sync'): (404, {'content-type': 'application/json; charset=utf-8'}, b''),
|
||||||
|
('PATCH', '/app.js'): (405,
|
||||||
|
{'allow': 'GET, HEAD',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('PATCH', '/health'): (405,
|
||||||
|
{'allow': 'GET, HEAD',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('PATCH', '/v1/auth/login'): (405,
|
||||||
|
{'allow': 'GET, HEAD, POST',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('PATCH', '/v1/me'): (405,
|
||||||
|
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('PATCH', '/v1/sync'): (405,
|
||||||
|
{'allow': 'GET, HEAD, POST',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('POST', '/app.js'): (405,
|
||||||
|
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('POST', '/health'): (405,
|
||||||
|
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('POST', '/v1/auth/login'): (400,
|
||||||
|
{'content-type': 'application/json; charset=utf-8'},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('POST', '/v1/me'): (405,
|
||||||
|
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('POST', '/v1/sync'): (401,
|
||||||
|
{'content-type': 'application/json; charset=utf-8'},
|
||||||
|
{'error': {'code': 'authorization_required',
|
||||||
|
'message': 'Authorization Bearer token is required'}}),
|
||||||
|
('PUT', '/app.js'): (405,
|
||||||
|
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('PUT', '/health'): (405,
|
||||||
|
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('PUT', '/v1/auth/login'): (405,
|
||||||
|
{'allow': 'GET, HEAD, POST',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('PUT', '/v1/me'): (405,
|
||||||
|
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('PUT', '/v1/sync'): (405,
|
||||||
|
{'allow': 'GET, HEAD, POST',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'Method Not Allowed\n'),
|
||||||
|
('bearer', ''): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'authorization_required',
|
||||||
|
'message': 'Authorization Bearer token is required'}}),
|
||||||
|
('bearer', 'Basic bad'): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'authorization_required',
|
||||||
|
'message': 'Authorization Bearer token is '
|
||||||
|
'required'}}),
|
||||||
|
('bearer', 'Bearer '): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'authorization_required',
|
||||||
|
'message': 'Authorization Bearer token is required'}}),
|
||||||
|
('bearer', 'Bearer bad'): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_token',
|
||||||
|
'message': 'Access token is invalid or expired'}}),
|
||||||
|
('bearer', 'bearer bad'): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'authorization_required',
|
||||||
|
'message': 'Authorization Bearer token is '
|
||||||
|
'required'}}),
|
||||||
|
('login', 0): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_credentials',
|
||||||
|
'message': 'Email or password is incorrect'}}),
|
||||||
|
('login', 1): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_credentials',
|
||||||
|
'message': 'Email or password is incorrect'}}),
|
||||||
|
('login', 2): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_credentials',
|
||||||
|
'message': 'Email or password is incorrect'}}),
|
||||||
|
('login', 3): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_credentials',
|
||||||
|
'message': 'Email or password is incorrect'}}),
|
||||||
|
('login', 4): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('login', 5): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_credentials',
|
||||||
|
'message': 'Email or password is incorrect'}}),
|
||||||
|
('login', 6): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('logout JSON', 0): (204, {}, b''),
|
||||||
|
('logout JSON', 1): (204, {}, b''),
|
||||||
|
('logout JSON', 2): (204, {}, b''),
|
||||||
|
('logout JSON', 3): (204, {}, b''),
|
||||||
|
('logout JSON', 4): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('logout JSON', 5): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('password boundary', 0): 201,
|
||||||
|
('password boundary', 1): 201,
|
||||||
|
('password boundary', 2): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_password',
|
||||||
|
'message': 'password is too long'}}),
|
||||||
|
('path', '/%2e%2e/app.js'): (200,
|
||||||
|
{'cache-control': 'public, max-age=3600',
|
||||||
|
'content-type': 'text/javascript; charset=utf-8'},
|
||||||
|
('web asset', 'app.js')),
|
||||||
|
('path', '/%61pp.js'): (200,
|
||||||
|
{'cache-control': 'public, max-age=3600',
|
||||||
|
'content-type': 'text/javascript; charset=utf-8'},
|
||||||
|
('web asset', 'app.js')),
|
||||||
|
('path', '//app.js'): (307,
|
||||||
|
{'content-type': 'text/html; charset=utf-8',
|
||||||
|
'location': '/app.js'},
|
||||||
|
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
|
||||||
|
('path', '/a/../app.js'): (307,
|
||||||
|
{'content-type': 'text/html; charset=utf-8',
|
||||||
|
'location': '/app.js'},
|
||||||
|
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
|
||||||
|
('path', '/app.js/'): (301,
|
||||||
|
{'cache-control': 'public, max-age=3600', 'location': '../app.js'},
|
||||||
|
b''),
|
||||||
|
('path', '/index.html'): (301, {'cache-control': 'no-cache', 'location': './'}, b''),
|
||||||
|
('path', '/v1'): (404,
|
||||||
|
{'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'404 page not found\n'),
|
||||||
|
('path', '/v1/'): (404,
|
||||||
|
{'cache-control': 'no-store',
|
||||||
|
'content-type': 'application/json; charset=utf-8'},
|
||||||
|
{'error': {'code': 'not_found', 'message': 'Endpoint not found'}}),
|
||||||
|
('path', '/v1/../app.js'): (307,
|
||||||
|
{'cache-control': 'no-store',
|
||||||
|
'content-type': 'text/html; charset=utf-8',
|
||||||
|
'location': '/app.js'},
|
||||||
|
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
|
||||||
|
('path', '/v1//me'): (307,
|
||||||
|
{'cache-control': 'no-store',
|
||||||
|
'content-type': 'text/html; charset=utf-8',
|
||||||
|
'location': '/v1/me'},
|
||||||
|
b'<a href="/v1/me">Temporary Redirect</a>.\n\n'),
|
||||||
|
('range', 'bytes=-10'): (206,
|
||||||
|
{'accept-ranges': 'bytes',
|
||||||
|
'content-range': 'bytes 1112-1121/1122',
|
||||||
|
'content-type': 'image/svg+xml'},
|
||||||
|
b'/>\n</svg>\n'),
|
||||||
|
('range', 'bytes=0-0,2-2'): (200,
|
||||||
|
{'accept-ranges': 'bytes', 'content-type': 'image/svg+xml'},
|
||||||
|
('web asset', 'icon.svg')),
|
||||||
|
('range', 'bytes=0-9'): (206,
|
||||||
|
{'accept-ranges': 'bytes',
|
||||||
|
'content-range': 'bytes 0-9/1122',
|
||||||
|
'content-type': 'image/svg+xml'},
|
||||||
|
b'<svg xmlns'),
|
||||||
|
('range', 'bytes=10-'): (206,
|
||||||
|
{'accept-ranges': 'bytes',
|
||||||
|
'content-range': 'bytes 10-1121/1122',
|
||||||
|
'content-type': 'image/svg+xml'},
|
||||||
|
b'="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="im'
|
||||||
|
b'g" aria-labelledby="title">\n <title id="title">Balance</tit'
|
||||||
|
b'le>\n <defs>\n <linearGradient id="bg" x1="70" y1="36" x2='
|
||||||
|
b'"446" y2="476" gradientUnits="userSpaceOnUse">\n <stop s'
|
||||||
|
b'top-color="#7777ef"/>\n <stop offset="1" stop-color="#42'
|
||||||
|
b'42b8"/>\n </linearGradient>\n <linearGradient id="shine"'
|
||||||
|
b' x1="144" y1="122" x2="366" y2="386" gradientUnits="userSpac'
|
||||||
|
b'eOnUse">\n <stop stop-color="#fff"/>\n <stop offset='
|
||||||
|
b'"1" stop-color="#dedfff"/>\n </linearGradient>\n </def'
|
||||||
|
b's>\n <rect x="28" y="28" width="456" height="456" rx="128" f'
|
||||||
|
b'ill="url(#bg)"/>\n <path d="M141 173c0-25 20-45 45-45h140c25'
|
||||||
|
b' 0 45 20 45 45v18H186c-25 0-45 20-45 45v-63Z" fill="#fff" op'
|
||||||
|
b'acity=".5"/>\n <path d="M116 222c0-28 23-51 51-51h179c28 0 5'
|
||||||
|
b'1 23 51 51v126c0 28-23 51-51 51H167c-28 0-51-23-51-51V222Z" '
|
||||||
|
b'fill="url(#shine)"/>\n <path d="M302 258h95v86h-95c-24 0-43-'
|
||||||
|
b'19-43-43s19-43 43-43Z" fill="#5b5bd6"/>\n <circle cx="306" c'
|
||||||
|
b'y="301" r="13" fill="#fff"/>\n <path d="M184 250h44M184 289h'
|
||||||
|
b'44M184 328h44" stroke="#5b5bd6" stroke-width="18" stroke-lin'
|
||||||
|
b'ecap="round" opacity=".72"/>\n</svg>\n'),
|
||||||
|
('range', 'bytes=10-1'): (416,
|
||||||
|
{'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'invalid range\n'),
|
||||||
|
('range', 'bytes=99999999-'): (416,
|
||||||
|
{'content-range': 'bytes */1122',
|
||||||
|
'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'invalid range: failed to overlap\n'),
|
||||||
|
('range', 'bytes=bad'): (416,
|
||||||
|
{'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'invalid range\n'),
|
||||||
|
('range', 'items=0-1'): (416,
|
||||||
|
{'content-type': 'text/plain; charset=utf-8'},
|
||||||
|
b'invalid range\n'),
|
||||||
|
('raw sync', 0): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
|
||||||
|
('raw sync', 1): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
|
||||||
|
('raw sync', 2): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
|
||||||
|
('raw sync', 3): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
|
||||||
|
('raw sync', 4): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('raw sync', 5): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('raw sync', 6): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_change',
|
||||||
|
'message': 'entity must be transaction, category, or '
|
||||||
|
'budget'}}),
|
||||||
|
('raw sync', 7): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('raw sync', 8): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('raw sync', 9): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
|
||||||
|
('raw sync', 10): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('refresh JSON', 0): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'refresh_token_required',
|
||||||
|
'message': 'Refresh token is required'}}),
|
||||||
|
('refresh JSON', 1): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'refresh_token_required',
|
||||||
|
'message': 'Refresh token is required'}}),
|
||||||
|
('refresh JSON', 2): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'refresh_token_required',
|
||||||
|
'message': 'Refresh token is required'}}),
|
||||||
|
('refresh JSON', 3): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_refresh_token',
|
||||||
|
'message': 'Refresh token is invalid or expired'}}),
|
||||||
|
('refresh JSON', 4): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('refresh JSON', 5): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('register', 0): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_email',
|
||||||
|
'message': 'Enter a valid email address'}}),
|
||||||
|
('register', 1): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_email',
|
||||||
|
'message': 'Enter a valid email address'}}),
|
||||||
|
('register', 2): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_email',
|
||||||
|
'message': 'Enter a valid email address'}}),
|
||||||
|
('register', 3): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_email',
|
||||||
|
'message': 'Enter a valid email address'}}),
|
||||||
|
('register', 4): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('register', 5): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_email',
|
||||||
|
'message': 'Enter a valid email address'}}),
|
||||||
|
('register', 6): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('signed claims', 0): (200, {}, {'email': 'e', 'id': 'u'}),
|
||||||
|
('signed claims', 1): (200, {}, {'email': 'e', 'id': 'u'}),
|
||||||
|
('signed claims', 2): (200, {}, {'email': '', 'id': 'u'}),
|
||||||
|
('signed claims', 3): (200, {}, {'email': '', 'id': 'u'}),
|
||||||
|
('signed claims', 4): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_token',
|
||||||
|
'message': 'Access token is invalid or expired'}}),
|
||||||
|
('signed claims', 5): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_token',
|
||||||
|
'message': 'Access token is invalid or expired'}}),
|
||||||
|
('signed claims', 6): (401,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_token',
|
||||||
|
'message': 'Access token is invalid or expired'}}),
|
||||||
|
('sync validation', 0): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_change',
|
||||||
|
'message': 'entity must be transaction, category, or '
|
||||||
|
'budget'}}),
|
||||||
|
('sync validation', 1): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_change',
|
||||||
|
'message': 'record id is invalid'}}),
|
||||||
|
('sync validation', 2): (200,
|
||||||
|
{},
|
||||||
|
{'changes': [{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': None,
|
||||||
|
'sequence': 1,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1}],
|
||||||
|
'cursor': 1,
|
||||||
|
'hasMore': False}),
|
||||||
|
('sync validation', 3): (200,
|
||||||
|
{},
|
||||||
|
{'changes': [{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': None,
|
||||||
|
'sequence': 1,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1}],
|
||||||
|
'cursor': 1,
|
||||||
|
'hasMore': False}),
|
||||||
|
('sync validation', 4): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_change',
|
||||||
|
'message': 'updatedAt is required'}}),
|
||||||
|
('sync validation', 5): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('sync validation', 6): (200,
|
||||||
|
{},
|
||||||
|
{'changes': [{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': None,
|
||||||
|
'sequence': 1,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1}],
|
||||||
|
'cursor': 1,
|
||||||
|
'hasMore': False}),
|
||||||
|
('sync validation', 7): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}}),
|
||||||
|
('sync validation', 8): (200,
|
||||||
|
{},
|
||||||
|
{'changes': [{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': None,
|
||||||
|
'sequence': 1,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1},
|
||||||
|
{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': {'amount': 125.5,
|
||||||
|
'nested': [None, True, 1],
|
||||||
|
'note': 'Тест €'},
|
||||||
|
'sequence': 2,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00.123456789Z',
|
||||||
|
'version': 2}],
|
||||||
|
'cursor': 2,
|
||||||
|
'hasMore': False}),
|
||||||
|
('sync validation', 9): (200,
|
||||||
|
{},
|
||||||
|
{'changes': [{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': None,
|
||||||
|
'sequence': 1,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1},
|
||||||
|
{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': {'amount': 125.5,
|
||||||
|
'nested': [None, True, 1],
|
||||||
|
'note': 'Тест €'},
|
||||||
|
'sequence': 2,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00.123456789Z',
|
||||||
|
'version': 2}],
|
||||||
|
'cursor': 2,
|
||||||
|
'hasMore': False}),
|
||||||
|
('sync validation', 10): (200,
|
||||||
|
{},
|
||||||
|
{'changes': [{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': None,
|
||||||
|
'sequence': 1,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1},
|
||||||
|
{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': {'amount': 125.5,
|
||||||
|
'nested': [None, True, 1],
|
||||||
|
'note': 'Тест €'},
|
||||||
|
'sequence': 2,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00.123456789Z',
|
||||||
|
'version': 2}],
|
||||||
|
'cursor': 2,
|
||||||
|
'hasMore': False}),
|
||||||
|
('sync validation', 11): (200,
|
||||||
|
{},
|
||||||
|
{'changes': [{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': None,
|
||||||
|
'sequence': 1,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1},
|
||||||
|
{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': {'amount': 125.5,
|
||||||
|
'nested': [None, True, 1],
|
||||||
|
'note': 'Тест €'},
|
||||||
|
'sequence': 2,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00.123456789Z',
|
||||||
|
'version': 2}],
|
||||||
|
'cursor': 2,
|
||||||
|
'hasMore': False}),
|
||||||
|
('sync validation', 12): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_change',
|
||||||
|
'message': 'updatedAt is too far in the future'}}),
|
||||||
|
('sync validation', 13): (400,
|
||||||
|
{},
|
||||||
|
{'error': {'code': 'invalid_change',
|
||||||
|
'message': 'payload is too large'}})},
|
||||||
|
'raw': {'raw database payloads': [(b'wire-0',
|
||||||
|
b'{ "duplicate":1, "duplicate":2, "n":123456789012345678901234'
|
||||||
|
b'567890, "big":1e999 }'),
|
||||||
|
(b'wire-1',
|
||||||
|
b'{"lone":"\\ud800","low":"\\udfff","pair":"\\ud83d\\ude00"}'),
|
||||||
|
(b'wire-2', b'{"invalid":"\xff\xc0\xed\xa0\x80"}'),
|
||||||
|
(b'wire-3',
|
||||||
|
b'{"html":"<>&","separator":"\xe2\x80\xa8\xe2\x80\xa9"}'),
|
||||||
|
(b'wire-4',
|
||||||
|
b'{"nested":[{"payload":{"id":"inner"}},"quote\\"end",null,true'
|
||||||
|
b']}'),
|
||||||
|
(b'\xef\xbf\xbd', b'{}')],
|
||||||
|
('unicode credentials', 0): (401,
|
||||||
|
{'error': {'code': 'invalid_credentials',
|
||||||
|
'message': 'Email or password is incorrect'}}),
|
||||||
|
('unicode credentials', 1): (401,
|
||||||
|
{'error': {'code': 'invalid_credentials',
|
||||||
|
'message': 'Email or password is incorrect'}}),
|
||||||
|
('unicode id', 0): {'changes': [{'deleted': False,
|
||||||
|
'entity': 'category',
|
||||||
|
'id': '�',
|
||||||
|
'payload': {},
|
||||||
|
'sequence': 6,
|
||||||
|
'updatedAt': '2025-01-02T00:00:00Z',
|
||||||
|
'version': 1}],
|
||||||
|
'cursor': 6,
|
||||||
|
'hasMore': False},
|
||||||
|
('unicode id', 1): {'changes': [], 'cursor': 6, 'hasMore': False},
|
||||||
|
('wire payload', 0): b'{"duplicate":1,"duplicate":2,"n":123456789012345678901234567890,'
|
||||||
|
b'"big":1e999}',
|
||||||
|
('wire payload', 1): b'{"lone":"\\ud800","low":"\\udfff","pair":"\\ud83d\\ude00"}',
|
||||||
|
('wire payload', 2): b'{"invalid":"\xff\xc0\xed\xa0\x80"}',
|
||||||
|
('wire payload', 3): b'{"html":"\\u003c\\u003e\\u0026","separator":"\\u2028\\u2029"}',
|
||||||
|
('wire payload', 4): b'{"nested":[{"payload":{"id":"inner"}},"quote\\"end",null,true]}'},
|
||||||
|
'scenario': {'/': (200,
|
||||||
|
{'cache-control': 'no-cache',
|
||||||
|
'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'",
|
||||||
|
'content-type': 'text/html; charset=utf-8',
|
||||||
|
'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=()',
|
||||||
|
'referrer-policy': 'no-referrer',
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
'x-frame-options': 'DENY'},
|
||||||
|
('web asset', 'index.html')),
|
||||||
|
'/app.js': (200,
|
||||||
|
{'cache-control': 'public, max-age=3600',
|
||||||
|
'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'",
|
||||||
|
'content-type': 'text/javascript; charset=utf-8',
|
||||||
|
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
|
||||||
|
'payment=()',
|
||||||
|
'referrer-policy': 'no-referrer',
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
'x-frame-options': 'DENY'},
|
||||||
|
('web asset', 'app.js')),
|
||||||
|
'/icon.svg': (200,
|
||||||
|
{'cache-control': 'public, max-age=3600',
|
||||||
|
'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'",
|
||||||
|
'content-type': 'image/svg+xml',
|
||||||
|
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
|
||||||
|
'payment=()',
|
||||||
|
'referrer-policy': 'no-referrer',
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
'x-frame-options': 'DENY'},
|
||||||
|
('web asset', 'icon.svg')),
|
||||||
|
'/manifest.webmanifest': (200,
|
||||||
|
{'cache-control': 'public, max-age=3600',
|
||||||
|
'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'",
|
||||||
|
'content-type': 'text/plain; charset=utf-8',
|
||||||
|
'permissions-policy': 'camera=(), microphone=(), '
|
||||||
|
'geolocation=(), payment=()',
|
||||||
|
'referrer-policy': 'no-referrer',
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
'x-frame-options': 'DENY'},
|
||||||
|
('web asset', 'manifest.webmanifest')),
|
||||||
|
'/missing-file': (404,
|
||||||
|
{'cache-control': None,
|
||||||
|
'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'",
|
||||||
|
'content-type': 'text/plain; charset=utf-8',
|
||||||
|
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
|
||||||
|
'payment=()',
|
||||||
|
'referrer-policy': 'no-referrer',
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
'x-frame-options': 'DENY'},
|
||||||
|
b'404 page not found\n'),
|
||||||
|
'/service-worker.js': (200,
|
||||||
|
{'cache-control': 'no-cache',
|
||||||
|
'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'",
|
||||||
|
'content-type': 'text/javascript; charset=utf-8',
|
||||||
|
'permissions-policy': 'camera=(), microphone=(), '
|
||||||
|
'geolocation=(), payment=()',
|
||||||
|
'referrer-policy': 'no-referrer',
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
'x-frame-options': 'DENY'},
|
||||||
|
('web asset', 'service-worker.js')),
|
||||||
|
'/styles.css': (200,
|
||||||
|
{'cache-control': 'public, max-age=3600',
|
||||||
|
'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'",
|
||||||
|
'content-type': 'text/css; charset=utf-8',
|
||||||
|
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
|
||||||
|
'payment=()',
|
||||||
|
'referrer-policy': 'no-referrer',
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
'x-frame-options': 'DENY'},
|
||||||
|
('web asset', 'styles.css')),
|
||||||
|
'GET /v1/me None': (401,
|
||||||
|
{'error': {'code': 'authorization_required',
|
||||||
|
'message': 'Authorization Bearer token is required'}}),
|
||||||
|
'GET /v1/missing None': (404,
|
||||||
|
{'error': {'code': 'not_found',
|
||||||
|
'message': 'Endpoint not found'}}),
|
||||||
|
"POST /v1/auth/login {'email': 'User@Example.COM', 'password': 'compatibility password'}": (401,
|
||||||
|
{'error': {'code': 'invalid_credentials',
|
||||||
|
'message': 'Email '
|
||||||
|
'or '
|
||||||
|
'password '
|
||||||
|
'is '
|
||||||
|
'incorrect'}}),
|
||||||
|
"POST /v1/auth/login {'unknown': 1}": (400,
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not '
|
||||||
|
'valid JSON'}}),
|
||||||
|
"POST /v1/auth/refresh {'refreshToken': 'bad'}": (401,
|
||||||
|
{'error': {'code': 'invalid_refresh_token',
|
||||||
|
'message': 'Refresh '
|
||||||
|
'token is '
|
||||||
|
'invalid or '
|
||||||
|
'expired'}}),
|
||||||
|
'POST /v1/auth/refresh {}': (400,
|
||||||
|
{'error': {'code': 'refresh_token_required',
|
||||||
|
'message': 'Refresh token is required'}}),
|
||||||
|
"POST /v1/auth/register {'email': 'a@b', 'password': 'short'}": (400,
|
||||||
|
{'error': {'code': 'invalid_password',
|
||||||
|
'message': 'password '
|
||||||
|
'must '
|
||||||
|
'contain '
|
||||||
|
'at '
|
||||||
|
'least '
|
||||||
|
'8 '
|
||||||
|
'characters'}}),
|
||||||
|
"POST /v1/auth/register {'email': 'bad', 'password': 'compatibility password'}": (400,
|
||||||
|
{'error': {'code': 'invalid_email',
|
||||||
|
'message': 'Enter '
|
||||||
|
'a '
|
||||||
|
'valid '
|
||||||
|
'email '
|
||||||
|
'address'}}),
|
||||||
|
'deleted': {'changes': [{'deleted': True,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'one',
|
||||||
|
'sequence': 5,
|
||||||
|
'updatedAt': '2025-01-02T00:00:00Z',
|
||||||
|
'version': 2}],
|
||||||
|
'cursor': 5,
|
||||||
|
'hasMore': False},
|
||||||
|
'duplicate': {'error': {'code': 'email_exists',
|
||||||
|
'message': 'An account with this email already exists'}},
|
||||||
|
'expired access': {'error': {'code': 'invalid_token',
|
||||||
|
'message': 'Access token is invalid or expired'}},
|
||||||
|
'first': {'changes': [{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': None,
|
||||||
|
'sequence': 1,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1},
|
||||||
|
{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'one',
|
||||||
|
'payload': {'amount': 125.5,
|
||||||
|
'nested': [None, True, 1],
|
||||||
|
'note': 'Тест €'},
|
||||||
|
'sequence': 2,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1},
|
||||||
|
{'deleted': False,
|
||||||
|
'entity': 'category',
|
||||||
|
'id': 'cat',
|
||||||
|
'payload': {'amount': 125.5,
|
||||||
|
'nested': [None, True, 1],
|
||||||
|
'note': 'Тест €'},
|
||||||
|
'sequence': 3,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1},
|
||||||
|
{'deleted': False,
|
||||||
|
'entity': 'budget',
|
||||||
|
'id': 'budget',
|
||||||
|
'payload': {'amount': 125.5,
|
||||||
|
'nested': [None, True, 1],
|
||||||
|
'note': 'Тест €'},
|
||||||
|
'sequence': 4,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1}],
|
||||||
|
'cursor': 4,
|
||||||
|
'hasMore': False},
|
||||||
|
'https://allowed.example': (204,
|
||||||
|
{'access-control-allow-headers': 'Authorization, '
|
||||||
|
'Content-Type',
|
||||||
|
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
||||||
|
'access-control-allow-origin': 'https://allowed.example',
|
||||||
|
'vary': 'Origin'}),
|
||||||
|
'https://denied.example': (204, {}),
|
||||||
|
'idempotent': {'changes': [], 'cursor': 4, 'hasMore': False},
|
||||||
|
'invalid bearer': {'error': {'code': 'invalid_token',
|
||||||
|
'message': 'Access token is invalid or expired'}},
|
||||||
|
"invalid sync {'changes': [{'entity': 'category', 'id': 'x'}]}": (400,
|
||||||
|
{'error': {'code': 'invalid_change',
|
||||||
|
'message': 'updatedAt '
|
||||||
|
'is '
|
||||||
|
'required'}}),
|
||||||
|
"invalid sync {'changes': [{'entity': 'transaction', 'id': '', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': {'amount': 125.5, 'note': 'Тест €', 'nested': [None, True, 1]}}]}": (400,
|
||||||
|
{'error': {'code': 'invalid_change',
|
||||||
|
'message': 'record '
|
||||||
|
'id '
|
||||||
|
'is '
|
||||||
|
'invalid'}}),
|
||||||
|
"invalid sync {'changes': [{'entity': 'transaction', 'id': 'x', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': None}]}": (200,
|
||||||
|
{'changes': [{'deleted': False,
|
||||||
|
'entity': 'transaction',
|
||||||
|
'id': 'x',
|
||||||
|
'payload': None,
|
||||||
|
'sequence': 1,
|
||||||
|
'updatedAt': '2025-01-01T00:00:00Z',
|
||||||
|
'version': 1}],
|
||||||
|
'cursor': 1,
|
||||||
|
'hasMore': False}),
|
||||||
|
"invalid sync {'changes': [{'entity': 'unknown', 'id': 'x', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': {'amount': 125.5, 'note': 'Тест €', 'nested': [None, True, 1]}}]}": (400,
|
||||||
|
{'error': {'code': 'invalid_change',
|
||||||
|
'message': 'entity '
|
||||||
|
'must '
|
||||||
|
'be '
|
||||||
|
'transaction, '
|
||||||
|
'category, '
|
||||||
|
'or '
|
||||||
|
'budget'}}),
|
||||||
|
"invalid sync {'cursor': -1}": (400,
|
||||||
|
{'error': {'code': 'invalid_sync_request',
|
||||||
|
'message': 'Cursor or change count is '
|
||||||
|
'invalid'}}),
|
||||||
|
"invalid sync {'extra': 1}": (400,
|
||||||
|
{'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid '
|
||||||
|
'JSON'}}),
|
||||||
|
'logout refresh': {'error': {'code': 'invalid_refresh_token',
|
||||||
|
'message': 'Refresh token is invalid or expired'}},
|
||||||
|
'malformed': {'error': {'code': 'invalid_json',
|
||||||
|
'message': 'Request body is not valid JSON'}},
|
||||||
|
'pagination': (505, 510),
|
||||||
|
'rate limit': {'error': {'code': 'rate_limited',
|
||||||
|
'message': 'Too many authentication requests'}},
|
||||||
|
'refresh replay': {'error': {'code': 'invalid_refresh_token',
|
||||||
|
'message': 'Refresh token is invalid or expired'}},
|
||||||
|
'registration disabled': {'error': {'code': 'registration_disabled',
|
||||||
|
'message': 'Registration is disabled'}}}}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user