This commit is contained in:
parent
1cb32e3d2c
commit
f79d830cf2
245
Cargo.lock
generated
245
Cargo.lock
generated
@ -26,6 +26,24 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atoi"
|
||||
version = "2.0.0"
|
||||
@ -120,6 +138,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@ -170,7 +197,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
@ -445,6 +475,19 @@ dependencies = [
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@ -696,6 +739,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@ -725,6 +774,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -752,6 +803,12 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
@ -945,6 +1002,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@ -1017,6 +1085,16 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@ -1035,6 +1113,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
@ -1062,7 +1146,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1104,7 +1188,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
name = "rhythm_backend"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@ -1114,6 +1200,7 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1124,7 +1211,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
@ -1202,6 +1289,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@ -1838,6 +1931,12 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@ -1868,7 +1967,9 @@ version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@ -1896,6 +1997,24 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "0.1.0"
|
||||
@ -1947,6 +2066,40 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
@ -2182,6 +2335,94 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
|
||||
@ -4,7 +4,9 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5.3"
|
||||
axum = "0.8.9"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
dotenvy = "0.15.7"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
@ -14,3 +16,4 @@ tokio = { version = "1.52.0", features = ["macros", "rt-multi-thread"] }
|
||||
tracing = "0.1.44"
|
||||
tower-http = { version = "0.6.6", features = ["trace"] }
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
uuid = { version = "1.18.1", features = ["serde", "v4"] }
|
||||
|
||||
9
bruno/.gitignore
vendored
Normal file
9
bruno/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
# Secrets
|
||||
.env*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
20
bruno/Untitled.yml
Normal file
20
bruno/Untitled.yml
Normal file
@ -0,0 +1,20 @@
|
||||
info:
|
||||
name: Untitled
|
||||
type: http
|
||||
seq: 1
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: "{{host}}/api/auth/register"
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
variables:
|
||||
- name: host
|
||||
value: http://localhost:8080
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
10
bruno/opencollection.yml
Normal file
10
bruno/opencollection.yml
Normal file
@ -0,0 +1,10 @@
|
||||
opencollection: 1.0.0
|
||||
|
||||
info:
|
||||
name: rhythm
|
||||
bundled: false
|
||||
extensions:
|
||||
bruno:
|
||||
ignore:
|
||||
- node_modules
|
||||
- .git
|
||||
95
docs/migration.md
Normal file
95
docs/migration.md
Normal file
@ -0,0 +1,95 @@
|
||||
# SQLx Migration Guide
|
||||
|
||||
This project uses SQLx migrations and runs them on application startup.
|
||||
|
||||
## 1) Install SQLx CLI
|
||||
|
||||
### Prerequisites
|
||||
- Rust toolchain installed (`rustup`)
|
||||
- PostgreSQL reachable from your machine
|
||||
- `DATABASE_URL` available
|
||||
|
||||
### Install command
|
||||
```bash
|
||||
cargo install sqlx-cli --no-default-features --features rustls,postgres
|
||||
```
|
||||
|
||||
Verify installation:
|
||||
```bash
|
||||
sqlx --version
|
||||
```
|
||||
|
||||
## 2) Configure `DATABASE_URL`
|
||||
|
||||
Set your database connection string:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL=postgres://DB_USERNAME:DB_PASSWORD@DB_HOST:DB_PORT/DB_NAME
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
export DATABASE_URL=postgres://rhythm:rhythm@localhost:5432/rhythm_db
|
||||
```
|
||||
|
||||
## 3) Create a new migration
|
||||
|
||||
Create a migration file:
|
||||
|
||||
```bash
|
||||
sqlx migrate add create_users
|
||||
```
|
||||
|
||||
This generates a timestamped SQL file under `migrations/`, for example:
|
||||
- `migrations/20260416103000_create_users.sql`
|
||||
|
||||
If you want reversible migrations (up/down):
|
||||
```bash
|
||||
sqlx migrate add -r create_users
|
||||
```
|
||||
|
||||
## 4) Run migrations manually
|
||||
|
||||
Apply pending migrations:
|
||||
|
||||
```bash
|
||||
sqlx migrate run
|
||||
```
|
||||
|
||||
Show migration status:
|
||||
|
||||
```bash
|
||||
sqlx migrate info
|
||||
```
|
||||
|
||||
Revert the most recent migration (only for reversible migrations):
|
||||
|
||||
```bash
|
||||
sqlx migrate revert
|
||||
```
|
||||
|
||||
## 5) Startup migrations (application)
|
||||
|
||||
Add this after creating the database pool in `src/main.rs`:
|
||||
|
||||
```rust
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Pending migrations are applied on every startup.
|
||||
- Applied migrations are tracked in the `_sqlx_migrations` table.
|
||||
- If a migration fails, the app fails fast and does not start listening.
|
||||
|
||||
## 6) Team workflow
|
||||
|
||||
- Create a new migration for every schema change.
|
||||
- Commit migration files to git.
|
||||
- Do not modify migration files that are already applied in shared environments.
|
||||
- Add a new migration to evolve schema safely.
|
||||
|
||||
## 7) Production notes
|
||||
|
||||
- Running migrations on startup in production is supported.
|
||||
- In multi-instance deployments, one instance may apply migrations while others wait/retry according to orchestration settings.
|
||||
- Prefer backward-compatible migrations for rolling deployments.
|
||||
9
migrations/20260416112927_create_users_table.sql
Normal file
9
migrations/20260416112927_create_users_table.sql
Normal file
@ -0,0 +1,9 @@
|
||||
-- Add migration script here
|
||||
|
||||
CREATE TABLE USERS (
|
||||
ID UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
EMAIL VARCHAR(255) NOT NULL UNIQUE,
|
||||
PASSWORD VARCHAR(255) NOT NULL,
|
||||
CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UPDATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@ -1 +1,3 @@
|
||||
pub mod database;
|
||||
pub mod model;
|
||||
pub mod user_repo;
|
||||
|
||||
1
src/db/model/mod.rs
Normal file
1
src/db/model/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod user;
|
||||
1
src/db/model/user/mod.rs
Normal file
1
src/db/model/user/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod user_dto;
|
||||
8
src/db/model/user/user_dto.rs
Normal file
8
src/db/model/user/user_dto.rs
Normal file
@ -0,0 +1,8 @@
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: uuid::Uuid,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
42
src/db/user_repo.rs
Normal file
42
src/db/user_repo.rs
Normal file
@ -0,0 +1,42 @@
|
||||
use crate::db::model::user::user_dto::User;
|
||||
use crate::error::AppError;
|
||||
use sqlx::{Postgres, Transaction};
|
||||
|
||||
pub async fn email_exists(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
email: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
SELECT TRUE
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(existing.unwrap_or(false))
|
||||
}
|
||||
|
||||
pub async fn create_user_in_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
email: &str,
|
||||
password_hash: &str,
|
||||
) -> Result<User, AppError> {
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
INSERT INTO users (email, password)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, email, password, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(email)
|
||||
.bind(password_hash)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
33
src/error.rs
33
src/error.rs
@ -1,3 +1,9 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@ -8,4 +14,31 @@ pub enum AppError {
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Migration(#[from] sqlx::migrate::MigrateError),
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match self {
|
||||
Self::Validation(_) => StatusCode::BAD_REQUEST,
|
||||
Self::Conflict(_) => StatusCode::CONFLICT,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
let body = Json(ErrorBody {
|
||||
error: self.to_string(),
|
||||
});
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,25 @@
|
||||
use axum::{Json, Router, routing::post};
|
||||
use serde::Serialize;
|
||||
use crate::http::model::register_user_req::RegisterUserReq;
|
||||
use crate::http::model::register_user_res::RegisterUserRes;
|
||||
use crate::service::auth_service;
|
||||
use axum::{Json, Router, extract::State, routing::post};
|
||||
|
||||
use crate::app_state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
status: &'static str,
|
||||
}
|
||||
use crate::error::AppError;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/register", post(register))
|
||||
Router::new()
|
||||
.route("/register", post(register))
|
||||
.route("/login", post(login))
|
||||
}
|
||||
|
||||
async fn register() -> Json<HealthResponse> {
|
||||
Json(HealthResponse { status: "ok" })
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<RegisterUserReq>,
|
||||
) -> Result<Json<RegisterUserRes>, AppError> {
|
||||
let response = auth_service::register(&state.db, body).await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn login() -> &'static str {
|
||||
"login not implemented yet"
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod api_router;
|
||||
mod auth_router;
|
||||
pub mod auth_router;
|
||||
mod health_router;
|
||||
pub mod model;
|
||||
|
||||
2
src/http/model/mod.rs
Normal file
2
src/http/model/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod register_user_req;
|
||||
pub mod register_user_res;
|
||||
7
src/http/model/register_user_req.rs
Normal file
7
src/http/model/register_user_req.rs
Normal file
@ -0,0 +1,7 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterUserReq {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
7
src/http/model/register_user_res.rs
Normal file
7
src/http/model/register_user_res.rs
Normal file
@ -0,0 +1,7 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterUserRes {
|
||||
pub id: uuid::Uuid,
|
||||
pub email: String,
|
||||
}
|
||||
@ -3,11 +3,13 @@ mod config;
|
||||
mod db;
|
||||
mod error;
|
||||
mod http;
|
||||
mod service;
|
||||
|
||||
use crate::db::database;
|
||||
use crate::error::AppError;
|
||||
use crate::http::api_router;
|
||||
use axum::Router;
|
||||
use sqlx::migrate;
|
||||
use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer};
|
||||
use tracing::{Level, info};
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
@ -27,6 +29,8 @@ async fn main() -> Result<(), AppError> {
|
||||
let pool = database::create_pool(&cfg.database_url()).await?;
|
||||
info!("database connection established");
|
||||
|
||||
migrate!().run(&pool).await?;
|
||||
|
||||
let state = app_state::AppState { db: pool };
|
||||
let app = Router::new()
|
||||
.nest("/api", api_router::router())
|
||||
|
||||
86
src/service/auth_service.rs
Normal file
86
src/service/auth_service.rs
Normal file
@ -0,0 +1,86 @@
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
|
||||
};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::db::user_repo;
|
||||
use crate::error::AppError;
|
||||
use crate::http::model::{register_user_req::RegisterUserReq, register_user_res::RegisterUserRes};
|
||||
|
||||
pub async fn register(
|
||||
pool: &sqlx::PgPool,
|
||||
req: RegisterUserReq,
|
||||
) -> Result<RegisterUserRes, AppError> {
|
||||
let started = Instant::now();
|
||||
let result = register_inner(pool, req).await;
|
||||
apply_obfuscation_delay(started).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn register_inner(
|
||||
pool: &sqlx::PgPool,
|
||||
req: RegisterUserReq,
|
||||
) -> Result<RegisterUserRes, AppError> {
|
||||
let email = req.email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return Err(AppError::Validation("email is required".to_string()));
|
||||
}
|
||||
|
||||
if req.password.len() < 8 {
|
||||
return Err(AppError::Validation(
|
||||
"password must be at least 8 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
if user_repo::email_exists(&mut tx, &email).await? {
|
||||
return Err(AppError::Validation(
|
||||
"invalid registration request".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(req.password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::Validation(format!("invalid password: {e}")))?
|
||||
.to_string();
|
||||
|
||||
let user = match user_repo::create_user_in_tx(&mut tx, &email, &password_hash).await {
|
||||
Ok(user) => user,
|
||||
Err(AppError::Db(sqlx::Error::Database(db_err)))
|
||||
if db_err.code().as_deref() == Some("23505") =>
|
||||
{
|
||||
return Err(AppError::Validation(
|
||||
"invalid registration request".to_string(),
|
||||
));
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(RegisterUserRes {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_obfuscation_delay(started: Instant) {
|
||||
let min_ms = 120;
|
||||
let max_ms = 320;
|
||||
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
let jitter = (nanos as u64) % (max_ms - min_ms + 1);
|
||||
let target = Duration::from_millis(min_ms + jitter);
|
||||
|
||||
let elapsed = started.elapsed();
|
||||
if target > elapsed {
|
||||
sleep(target - elapsed).await;
|
||||
}
|
||||
}
|
||||
1
src/service/mod.rs
Normal file
1
src/service/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod auth_service;
|
||||
Loading…
x
Reference in New Issue
Block a user