loco rewrite
This commit is contained in:
10
.cargo/config.toml
Normal file
10
.cargo/config.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[alias]
|
||||
loco = "run --"
|
||||
loco-tool = "run --"
|
||||
|
||||
playground = "run --example playground"
|
||||
|
||||
# https://github.com/rust-lang/rust/issues/141626
|
||||
# (can be removed once link.exe is fixed)
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld"
|
||||
102
.github/workflows/ci.yaml
vendored
Normal file
102
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
RUST_TOOLCHAIN: stable
|
||||
TOOLCHAIN_PROFILE: minimal
|
||||
|
||||
jobs:
|
||||
rustfmt:
|
||||
name: Check Style
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
components: rustfmt
|
||||
- name: Run cargo fmt
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
|
||||
clippy:
|
||||
name: Run Clippy
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Run cargo clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms
|
||||
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_DB: postgres_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
# Set health checks to wait until postgres has started
|
||||
options: --health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Run cargo test
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --all-features --all
|
||||
env:
|
||||
REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}}
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test
|
||||
|
||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
**/config/local.yaml
|
||||
**/config/*.local.yaml
|
||||
**/config/production.yaml
|
||||
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
|
||||
# include cargo lock
|
||||
!Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
.env
|
||||
.env.production
|
||||
2
.rustfmt.toml
Normal file
2
.rustfmt.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
max_width = 100
|
||||
use_small_heuristics = "Default"
|
||||
53
Cargo.toml
Normal file
53
Cargo.toml
Normal file
@@ -0,0 +1,53 @@
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "universal_web"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
default-run = "universal_web-cli"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[workspace.dependencies]
|
||||
loco-rs = { version = "0.16" }
|
||||
|
||||
[dependencies]
|
||||
loco-rs = { workspace = true }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1" }
|
||||
tokio = { version = "1.45", default-features = false, features = [
|
||||
"rt-multi-thread",
|
||||
] }
|
||||
async-trait = { version = "0.1" }
|
||||
axum = { version = "0.8" }
|
||||
tracing = { version = "0.1" }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
regex = { version = "1.11" }
|
||||
migration = { path = "migration" }
|
||||
sea-orm = { version = "1.1", features = [
|
||||
"sqlx-sqlite",
|
||||
"sqlx-postgres",
|
||||
"runtime-tokio-rustls",
|
||||
"macros",
|
||||
] }
|
||||
chrono = { version = "0.4" }
|
||||
validator = { version = "0.20" }
|
||||
uuid = { version = "1.6", features = ["v4"] }
|
||||
include_dir = { version = "0.7" }
|
||||
# view engine i18n
|
||||
fluent-templates = { version = "0.13", features = ["tera"] }
|
||||
unic-langid = { version = "0.9" }
|
||||
# /view engine
|
||||
axum-extra = { version = "0.10", features = ["form"] }
|
||||
|
||||
[[bin]]
|
||||
name = "universal_web-cli"
|
||||
path = "src/bin/main.rs"
|
||||
required-features = []
|
||||
|
||||
[dev-dependencies]
|
||||
loco-rs = { workspace = true, features = ["testing"] }
|
||||
serial_test = { version = "3.1.1" }
|
||||
rstest = { version = "0.25" }
|
||||
insta = { version = "1.34", features = ["redactions", "yaml", "filters"] }
|
||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Welcome to Loco :train:
|
||||
|
||||
[Loco](https://loco.rs) is a web and API framework running on Rust.
|
||||
|
||||
This is the **SaaS starter** which includes a `User` model and authentication based on JWT.
|
||||
It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server.
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sh
|
||||
cargo loco start
|
||||
```
|
||||
|
||||
```sh
|
||||
$ cargo loco start
|
||||
Finished dev [unoptimized + debuginfo] target(s) in 21.63s
|
||||
Running `target/debug/myapp start`
|
||||
|
||||
:
|
||||
:
|
||||
:
|
||||
|
||||
controller/app_routes.rs:203: [Middleware] Adding log trace id
|
||||
|
||||
▄ ▀
|
||||
▀ ▄
|
||||
▄ ▀ ▄ ▄ ▄▀
|
||||
▄ ▀▄▄
|
||||
▄ ▀ ▀ ▀▄▀█▄
|
||||
▀█▄
|
||||
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
|
||||
██████ █████ ███ █████ ███ █████ ███ ▀█
|
||||
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
|
||||
██████ █████ ███ █████ █████ ███ ████▄
|
||||
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
|
||||
██████ █████ ███ ████ ███ █████ ███ ████▀
|
||||
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
https://loco.rs
|
||||
|
||||
environment: development
|
||||
database: automigrate
|
||||
logger: debug
|
||||
compilation: debug
|
||||
modes: server
|
||||
|
||||
listening on http://localhost:5150
|
||||
```
|
||||
|
||||
## Full Stack Serving
|
||||
|
||||
You can check your [configuration](config/development.yaml) to pick either frontend setup or server-side rendered template, and activate the relevant configuration sections.
|
||||
|
||||
|
||||
## Getting help
|
||||
|
||||
Check out [a quick tour](https://loco.rs/docs/getting-started/tour/) or [the complete guide](https://loco.rs/docs/getting-started/guide/).
|
||||
399
REWRITE_SPEC.md
Normal file
399
REWRITE_SPEC.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Universal Web — Loco.rs Rewrite Specification
|
||||
|
||||
This document is the implementation spec for re-building the existing **Axum** app
|
||||
(parent directory `../`) on top of **Loco.rs**. It captures every feature so the
|
||||
rewrite reaches parity. The GUI is being rewritten as part of this effort, so
|
||||
templates/markup are *not* meant to be ported verbatim — only behaviour and routes.
|
||||
|
||||
- **Source app:** `../` — Axum 0.8, sqlx, Askama, axum-login, OpenDAL.
|
||||
- **Target app:** this directory — Loco 0.16 (SaaS, server-side rendered), SeaORM 1.1, Tera.
|
||||
- **Scaffold already generated by `loco new`:** auth controller, `users` model + migration,
|
||||
auth mailer, background worker example, Tera view engine, i18n.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stack mapping (old → Loco)
|
||||
|
||||
| Concern | Old (Axum) | Loco target |
|
||||
|--------------------|---------------------------------------------|----------------------------------------------------------|
|
||||
| HTTP framework | Axum 0.8 + tower | Loco (Axum under the hood) — `controllers/` |
|
||||
| DB access | sqlx 0.8 (raw SQL, `query_as`) | SeaORM 1.1 — entities in `models/_entities/` |
|
||||
| Migrations | `migrations/*.sql` (sqlx-cli) | `migration/` crate (SeaORM, Rust DSL) |
|
||||
| Templating | Askama (compile-time) | Tera (`assets/views/`) via `view_engine` initializer |
|
||||
| Auth/session | axum-login + tower-sessions (PG store) | **Decision needed — see §3.1.** Loco ships JWT auth. |
|
||||
| Password hashing | argon2 0.5 | Loco built-in (`AuthnUser`/`hash_password`) — argon2 |
|
||||
| File storage | OpenDAL 0.50 (fs/S3/Azure/GCS) | Loco `storage` module (local/S3/multi-mirror) |
|
||||
| Background jobs | none (manual) | Loco workers (`workers/`) — bg mode `async` selected |
|
||||
| Email | none (verification stored, not sent) | Loco mailers (`mailers/`) — scaffold already present |
|
||||
| API docs | utoipa + Swagger UI + Scalar | Manual — see §13 |
|
||||
| Validation | garde | `validator` crate (already a dep) |
|
||||
| Config | `.env` + `config.rs` singleton | `config/{development,production,test}.yaml` |
|
||||
| Logging | tracing-subscriber | Loco logger (configured in yaml) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature inventory (parity checklist)
|
||||
|
||||
The old app ships these feature modules. Each must exist in the rewrite:
|
||||
|
||||
- [ ] **Auth** — register, login, logout, current-user, email verification
|
||||
- [ ] **RBAC** — 4 roles, ~14 permissions, per-request permission loading
|
||||
- [ ] **Admin** — dashboard, user management, role assignment, audit log
|
||||
- [ ] **Blog** — articles CRUD, publish workflow, public listing, view counts
|
||||
- [ ] **Audio dashboard** — albums + tracks + tags CRUD, publish workflow
|
||||
- [ ] **Audio streaming** — range-aware track/file streaming, raw upload
|
||||
- [ ] **Audio player** — persistent bottom-bar player (frontend)
|
||||
- [ ] **Images** — upload + serve, used as cover/featured images
|
||||
- [ ] **Theme** — per-user light/dark preference
|
||||
- [ ] **Storage** — pluggable backend (fs default, S3/Azure/GCS capable)
|
||||
- [ ] **Home + layout** — landing page, dynamic navbar, footer
|
||||
- [ ] **Swagger/OpenAPI** — API docs (optional, lower priority)
|
||||
|
||||
---
|
||||
|
||||
## 3. Key migration decisions / gotchas
|
||||
|
||||
### 3.1 Sessions vs JWT
|
||||
|
||||
The old app uses **cookie sessions** (axum-login + `tower-sessions-sqlx-store`,
|
||||
1-day inactivity timeout, PG-backed `session` table). Loco's SaaS starter ships
|
||||
**JWT bearer auth** by default.
|
||||
|
||||
Because this is a **server-side rendered** app (forms post HTML, navbar reflects
|
||||
auth state), bearer tokens are awkward. Recommended path:
|
||||
|
||||
- Keep Loco's `users` model + password hashing + the `auth` controller for the
|
||||
*API-style* endpoints.
|
||||
- Add **cookie-session middleware** for the HTML pages. Loco exposes the Axum
|
||||
router, so `tower-sessions` can be layered in an initializer
|
||||
(`src/initializers/`). Store the user id in the session.
|
||||
- Alternatively store the JWT in an `HttpOnly` cookie and add an extractor that
|
||||
reads it from the cookie instead of the `Authorization` header.
|
||||
|
||||
Pick one and apply it consistently. The old behaviour to match: login sets a
|
||||
cookie, navbar/`/auth/me` reflect it, logout clears it, ~1-day expiry.
|
||||
|
||||
### 3.2 sqlx → SeaORM
|
||||
|
||||
Every raw query becomes a SeaORM entity + `ActiveModel`. Generate entities from
|
||||
migrations with `cargo loco db entities`. Slug-uniqueness and `ON CONFLICT DO
|
||||
NOTHING` semantics must be re-expressed (unique index + handle the error).
|
||||
|
||||
### 3.3 Askama → Tera
|
||||
|
||||
Templates are NOT ported. Re-author markup as Tera under `assets/views/`. The new
|
||||
GUI may keep HTMX + Alpine + DaisyUI (still works with server-rendered Tera) or go
|
||||
a different direction — that is a GUI decision, not a backend one.
|
||||
|
||||
### 3.4 OpenDAL → Loco storage
|
||||
|
||||
Loco has its own `storage` abstraction (local, S3, multi-backend mirror/replica).
|
||||
Map the old `Storage`/`ImageStorage`/`AudioStorage` wrappers onto it. Default to
|
||||
local (`uploads/`); keep S3/Azure/GCS reachable via config only.
|
||||
|
||||
### 3.5 Range requests for audio
|
||||
|
||||
The old `stream_track` forwards the request so `ServeFile` honours `Range`
|
||||
headers (seeking/progressive playback). The Loco equivalent must also emit
|
||||
`Accept-Ranges`/`Content-Range` — use `tower-http` `ServeFile`/`ServeDir` or a
|
||||
range-aware handler. Do not regress this; it is required for the player's seek bar.
|
||||
|
||||
---
|
||||
|
||||
## 4. Database schema
|
||||
|
||||
Old app: 8 migrations under `../migrations/`. Re-create as SeaORM migrations in
|
||||
`migration/src/` (the `loco new` scaffold already created `m20220101_000001_users`).
|
||||
Prefer Loco generators: `cargo loco generate model <name> ...`.
|
||||
|
||||
### 4.1 `users`
|
||||
| column | type | notes |
|
||||
|-------------------|--------------|----------------------------------------|
|
||||
| id | UUID PK | `gen_random_uuid()` |
|
||||
| email | VARCHAR(255) | UNIQUE NOT NULL |
|
||||
| password_hash | TEXT | NOT NULL (argon2) |
|
||||
| email_verified | BOOLEAN | default FALSE |
|
||||
| email_verified_at | TIMESTAMPTZ | nullable |
|
||||
| theme | VARCHAR(10) | default `'light'` |
|
||||
| created_at | TIMESTAMPTZ | default NOW |
|
||||
| updated_at | TIMESTAMPTZ | default NOW |
|
||||
|
||||
> Loco's scaffold `users` table differs (has `pid`, `api_key`, reset/verification
|
||||
> token columns). Reconcile: either adopt Loco's columns and add `theme`, or align
|
||||
> Loco's model to this schema. Adopting Loco's is less work — it already wires
|
||||
> tokens for verification/reset.
|
||||
|
||||
### 4.2 `email_verifications`
|
||||
`id UUID PK`, `user_id UUID FK→users ON DELETE CASCADE`, `token VARCHAR(255) UNIQUE`,
|
||||
`expires_at TIMESTAMPTZ`, `created_at TIMESTAMPTZ`. Indexes on `token`, `user_id`.
|
||||
> Loco's `users` table already carries verification tokens — this table may become
|
||||
> redundant if you adopt Loco's auth columns.
|
||||
|
||||
### 4.3 `user_roles`
|
||||
`user_id UUID FK→users CASCADE`, `role VARCHAR(50)` (`user`|`moderator`|`admin`|
|
||||
`super_admin`), `assigned_by UUID FK→users ON DELETE SET NULL`, `assigned_at
|
||||
TIMESTAMPTZ`. **PK = (user_id, role).** Index on `user_id`. Migration back-fills
|
||||
`user` role for every existing user.
|
||||
|
||||
### 4.4 `blog_articles`
|
||||
`id UUID PK`, `title VARCHAR(500)`, `slug VARCHAR(500) UNIQUE`, `content TEXT`,
|
||||
`excerpt VARCHAR(1000) NULL`, `published BOOL default false`, `author_id UUID
|
||||
FK→users CASCADE`, `featured_image_id VARCHAR(500) NULL`, `view_count INT default 0`,
|
||||
`created_at`, `updated_at` (trigger-updated), `published_at TIMESTAMPTZ NULL`.
|
||||
Indexes: `slug`, `(published, published_at DESC)`, `author_id`.
|
||||
|
||||
### 4.5 `audit_logs`
|
||||
`id UUID PK`, `admin_user_id UUID FK→users CASCADE`, `action VARCHAR(100)`,
|
||||
`target_type VARCHAR(50) NULL`, `target_id UUID NULL`, `details JSONB NULL`,
|
||||
`ip_address INET NULL`, `user_agent TEXT NULL`, `created_at TIMESTAMPTZ`.
|
||||
Indexes: `admin_user_id`, `action`, `(target_type,target_id)`, `created_at DESC`.
|
||||
|
||||
### 4.6 `audio_albums`
|
||||
`id UUID PK`, `title VARCHAR(500)`, `slug VARCHAR(500) UNIQUE`, `description TEXT
|
||||
NULL`, `cover_image_id VARCHAR(500) NULL`, `artist VARCHAR(500) NULL`, `release_date
|
||||
DATE NULL`, `published BOOL default false`, `uploader_id UUID FK→users CASCADE`,
|
||||
`view_count INT default 0`, `created_at`, `updated_at` (trigger), `published_at NULL`.
|
||||
Indexes: `slug`, `published`, `uploader_id`.
|
||||
|
||||
### 4.7 `audio_tracks`
|
||||
`id UUID PK`, `album_id UUID FK→audio_albums CASCADE`, `title VARCHAR(500)`, `slug
|
||||
VARCHAR(500)`, `audio_file_id VARCHAR(500) NOT NULL`, `track_number INT NULL`,
|
||||
`duration INT NULL` (seconds), `featured BOOL default false`, `play_count INT
|
||||
default 0`, `created_at`, `updated_at` (trigger). **UNIQUE(album_id, slug).**
|
||||
|
||||
### 4.8 `audio_tags` + `audio_track_tags`
|
||||
`audio_tags`: `id UUID PK`, `name VARCHAR(100) UNIQUE`, `slug VARCHAR(100) UNIQUE`,
|
||||
`created_at`. `audio_track_tags` (M:N junction): `track_id UUID FK→audio_tracks
|
||||
CASCADE`, `tag_id UUID FK→audio_tags CASCADE`, `created_at`, **PK=(track_id,tag_id)**.
|
||||
|
||||
> The old schema uses `updated_at` triggers. SeaORM convention is to set
|
||||
> `updated_at` in the `ActiveModel` (`before_save` hook) instead — either works.
|
||||
|
||||
---
|
||||
|
||||
## 5. Routes (parity table)
|
||||
|
||||
`method path → behaviour (auth requirement)`. Loco controllers live in
|
||||
`src/controllers/`; register each with `.add_route(...)` in `app.rs`.
|
||||
|
||||
### Auth (`/auth`)
|
||||
- `GET /auth/login` → login page (public)
|
||||
- `POST /auth/login` → login, HTML/HTMX response; sets session cookie
|
||||
- `POST /auth/login/json` → login, JSON response
|
||||
- `GET /auth/register` → register page (public)
|
||||
- `POST /auth/register` → register, HTML/HTMX
|
||||
- `POST /auth/register/json` → register, JSON
|
||||
- `POST /auth/logout` → clear session
|
||||
- `GET /auth/me` → current user (auth required)
|
||||
- `GET /auth/verify?token=` → verify email
|
||||
- `POST /auth/verify/resend` → resend verification (auth required)
|
||||
|
||||
### RBAC (`/rbac`)
|
||||
- `GET /rbac/permissions` → list all permissions (public)
|
||||
- `GET /rbac/roles` → list all roles (public)
|
||||
- `GET /rbac/me` → current user roles+permissions (auth)
|
||||
- `GET /rbac/users/{id}/roles` → (auth)
|
||||
- `GET /rbac/users/{id}/permissions` → (auth)
|
||||
- `POST /rbac/users/{id}/roles` → assign role (SuperAdmin)
|
||||
- `DELETE /rbac/users/{id}/roles/{role}` → remove role (SuperAdmin)
|
||||
|
||||
### Blog
|
||||
- `GET /blog` → published articles (public)
|
||||
- `GET /blog/{slug}` → article, increments `view_count` (public)
|
||||
- `GET /admin/blog` & `/admin/blog/articles` → admin list (Moderator+)
|
||||
- `GET|POST /admin/blog/articles/create` → create form / submit (Moderator+)
|
||||
- `GET|POST /admin/blog/articles/{id}/edit` → edit form / submit (Moderator+)
|
||||
- `GET /admin/blog/articles/{id}/delete` → delete (Moderator+)
|
||||
|
||||
### Admin (`/admin`)
|
||||
- `GET /admin` → redirect to `/admin/dashboard`
|
||||
- `GET /admin/dashboard` → dashboard + system stats (Moderator+)
|
||||
- `GET /admin/users` & `/admin/users/list` → user list, filter+paginate (Admin+)
|
||||
- `GET /admin/users/{id}` → user detail (Admin+)
|
||||
- `GET /admin/users/{id}/roles` → roles form (Admin+)
|
||||
- `POST /admin/users/{id}/roles/assign` → assign (Admin+)
|
||||
- `POST /admin/users/{id}/roles/{role}/remove` → remove (Admin+)
|
||||
- `GET /admin/audit-logs` → audit log list (Moderator+)
|
||||
|
||||
### Audio (public + admin)
|
||||
- `POST /audio/upload` → raw audio upload (auth)
|
||||
- `GET /audio/stream/{filename}` → stream raw file, **range-aware** (public)
|
||||
- `GET /audio/albums` → published albums (public)
|
||||
- `GET /audio/albums/{slug}` → album + tracks (public)
|
||||
- `GET /audio/tracks/{id}/stream` → stream track, **range-aware** (public)
|
||||
- `GET /admin/audio/albums` → admin album list (Moderator+)
|
||||
- `GET|POST /admin/audio/albums/create` → (Moderator+)
|
||||
- `GET|POST /admin/audio/albums/{id}/edit` → (Moderator+)
|
||||
- `GET /admin/audio/albums/{id}/delete` → (Moderator+)
|
||||
- `GET /admin/audio/albums/{id}/tracks` → track list (Moderator+)
|
||||
- `GET /admin/audio/albums/{id}/tracks/upload` → upload form (Moderator+)
|
||||
- `POST /admin/audio/albums/{id}/tracks/upload-file` → multipart upload, 50 MB (Moderator+)
|
||||
- `GET|POST /admin/audio/tracks/{id}/edit` → (Moderator+)
|
||||
- `GET /admin/audio/tracks/{id}/delete` → (Moderator+)
|
||||
- `GET /admin/audio/tags` → tag list (Moderator+)
|
||||
- `POST /admin/audio/tags` → create tag (Moderator+)
|
||||
|
||||
### Images
|
||||
- `POST /images/upload` → upload, validate ext+MIME, max 10 MB (auth)
|
||||
- `GET /images/serve/{filename}` → serve with MIME + cache headers (public)
|
||||
|
||||
### Misc
|
||||
- `GET /` → home (public)
|
||||
- `GET /layout/navbar` → navbar fragment reflecting auth state (public)
|
||||
- `GET /theme` → current theme; `GET /theme/set?...` → set theme (public)
|
||||
- `GET /static/*` → static files
|
||||
- `GET /swagger-ui/`, `GET /api-docs/openapi.json` → API docs
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature details
|
||||
|
||||
### 6.1 Auth
|
||||
- Register: validate email + password (8–64 chars), reject duplicate email,
|
||||
argon2-hash password, insert user, assign default `user` role.
|
||||
- Login: look up by email, verify password (constant-time), create session.
|
||||
- **Admin bootstrap:** on successful login, if `user.email` (case-insensitive)
|
||||
equals config `admin_email`, assign `SuperAdmin` role (idempotent) and write an
|
||||
`audit_logs` row with `action="admin_bootstrap"`. This is how the first admin is
|
||||
created — there is no seed user. Keep this behaviour.
|
||||
- Email verification: token table + `email_verified`/`email_verified_at`. The old
|
||||
app stored tokens but never emailed them — the Loco rewrite **should actually
|
||||
send** the email via the scaffolded `mailers/auth`.
|
||||
- Every response that changes auth state should let the navbar refresh
|
||||
(`GET /layout/navbar`).
|
||||
|
||||
### 6.2 RBAC
|
||||
- **Roles:** `SuperAdmin`, `Admin` (same perms as SuperAdmin), `Moderator`, `User`.
|
||||
- **Permissions (~14):** `users.{create,read,update,delete,manage_roles}`,
|
||||
`roles.assign`, `content.{create,read,update,delete,moderate}`,
|
||||
`images.{create,read,update,delete}`.
|
||||
- Role→permission mapping:
|
||||
- SuperAdmin / Admin → all permissions.
|
||||
- Moderator → `content.read/update/moderate`, `users.read`, `images.read/delete`.
|
||||
- User (default) → `content.read`, `images.create/read`.
|
||||
- A **middleware** loads the current user's `UserPermissions` into request
|
||||
extensions on every request so handlers/views can check access. In Loco, do this
|
||||
with a middleware layer or a custom extractor. Provide guard helpers equivalent
|
||||
to "Moderator+", "Admin+", "SuperAdmin".
|
||||
|
||||
### 6.3 Admin
|
||||
- Dashboard with system stats (user counts, content counts) — Moderator+.
|
||||
- User management: list with search filter + pagination, detail view — Admin+.
|
||||
- Role assign/remove per user, each writing an `audit_logs` entry — Admin+.
|
||||
- Audit log viewer — Moderator+.
|
||||
- `AuditLog` fields: action, target_type, target_id, JSONB details, ip_address,
|
||||
user_agent. Populate IP + user-agent from request headers.
|
||||
|
||||
### 6.4 Blog
|
||||
- Public: list published articles (paginate), view by slug (increments `view_count`).
|
||||
- Admin (Moderator+): create / edit / delete, draft↔publish (`published_at` set on
|
||||
publish), drafts visible only in admin.
|
||||
- Slug auto-generated from title: lowercase, alphanumeric + hyphens, collapse
|
||||
repeats; uniqueness enforced by DB — surface a clear "already exists" error.
|
||||
- `author_id` = current user. `featured_image_id` references an uploaded image id.
|
||||
|
||||
### 6.5 Audio dashboard
|
||||
- **Albums:** CRUD, publish workflow, public list shows published only, slug URLs,
|
||||
`cover_image_id` references an image, `view_count`.
|
||||
- **Tracks:** belong to an album, multipart upload (50 MB cap, extensions
|
||||
`mp3 wav ogg flac aac m4a webm`), `track_number`, `duration` (seconds, nullable —
|
||||
upload does not extract it; the player reads it from the audio element instead),
|
||||
`featured`, `play_count`, slug unique per album.
|
||||
- **Tags:** create + assign to tracks (M:N). Tag has unique name + slug.
|
||||
|
||||
### 6.6 Audio streaming & player
|
||||
- `stream_track` / `stream_handler` must honour `Range` requests (see §3.5).
|
||||
- The old app has a **persistent bottom-bar player** (`templates/player/player.html`):
|
||||
hidden `<audio>` + Alpine.js, play/pause, prev/next, seek bar, volume persisted to
|
||||
`localStorage`, auto-advance on track end. It is a sibling of the main content so
|
||||
HTMX navigation never tears it down — music keeps playing across pages.
|
||||
- Pages drive it via one global JS API: `MusicPlayer.playQueue(containerSelector,
|
||||
startIndex)`, reading `data-*` attributes off track rows (no JSON in markup).
|
||||
- This is a **frontend concern** — reproduce the behaviour in the new GUI; the
|
||||
backend only needs the range-aware stream endpoints + track metadata.
|
||||
|
||||
### 6.7 Images
|
||||
- Upload: multipart, validate extension (`jpg jpeg png webp gif`) and MIME, max
|
||||
10 MB, store via storage backend, return a file id.
|
||||
- Serve: public, correct MIME (guessed from extension), long-cache headers.
|
||||
- Image ids are referenced by `blog_articles.featured_image_id` and
|
||||
`audio_albums.cover_image_id` (stored as strings, not FKs).
|
||||
|
||||
### 6.8 Theme
|
||||
- Per-user `theme` column (`light`/`dark`); also a cookie for anonymous users.
|
||||
- `GET /theme` returns current, `GET /theme/set` toggles/sets it.
|
||||
- Maps to DaisyUI themes in the old GUI (`winter`/`dracula`). New GUI decides its
|
||||
own theming, but keep the per-user persisted preference.
|
||||
|
||||
### 6.9 Storage
|
||||
- Default backend: local filesystem under `uploads/` (`uploads/images`,
|
||||
`uploads/audio`). Keep S3/Azure/GCS reachable via config only.
|
||||
- Path-traversal protection on every user-supplied filename/path.
|
||||
- Use Loco's `storage` module; configure backends in `config/*.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration / env
|
||||
|
||||
Old `.env`:
|
||||
```
|
||||
DATABASE_URL=postgres://uni_web_user:1@localhost/universal_web
|
||||
ADMIN_EMAIL=filippriec@gmail.com
|
||||
```
|
||||
In Loco, move these into `config/development.yaml` (DB url under `database:`) and
|
||||
add a custom `settings:` entry for `admin_email` (read via `ctx.config.settings`).
|
||||
The old default bind address is `0.0.0.0:3000` — set under `server:` in the yaml.
|
||||
|
||||
---
|
||||
|
||||
## 8. What the Loco scaffold already gives you
|
||||
|
||||
Already generated in this directory — reuse, don't rebuild:
|
||||
- `src/controllers/auth.rs` — register / login / verify / forgot / reset endpoints.
|
||||
- `src/models/users.rs` + `_entities/users.rs` — user model with password hashing.
|
||||
- `src/mailers/auth/` — welcome / forgot-password / magic-link email templates.
|
||||
- `src/workers/downloader.rs` — example background worker (bg mode = `async`).
|
||||
- `src/initializers/view_engine.rs` — Tera view engine + i18n wiring.
|
||||
- `migration/` — SeaORM migration crate.
|
||||
- `config/{development,production,test}.yaml` — config files.
|
||||
|
||||
---
|
||||
|
||||
## 9. Suggested implementation order
|
||||
|
||||
1. **DB & models** — port all 8 tables as SeaORM migrations; `cargo loco db
|
||||
entities`; reconcile `users` with the scaffold (§4.1).
|
||||
2. **Auth + sessions** — settle §3.1, get register/login/logout/me working,
|
||||
including admin-bootstrap.
|
||||
3. **RBAC** — roles/permissions, the permission-loading middleware, guard helpers.
|
||||
4. **Storage + images** — storage backend, image upload/serve (unblocks blog/audio
|
||||
cover images).
|
||||
5. **Blog** — CRUD + publish + public pages.
|
||||
6. **Audio dashboard** — albums, tracks (multipart upload), tags.
|
||||
7. **Audio streaming + player** — range-aware endpoints, then the player in the GUI.
|
||||
8. **Admin** — dashboard, user management, role UI, audit log.
|
||||
9. **Theme**, **home/layout/navbar**.
|
||||
10. **Swagger/OpenAPI** (optional), tests, polish.
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing
|
||||
|
||||
The old app has 19 tests (17 integration hitting a live server + 2 multipart
|
||||
upload) plus unit tests; see `../TESTING.md`. Loco has a first-class testing story
|
||||
(`#[tokio::test]` + `loco-rs` testing feature, `serial_test`, `insta` snapshots —
|
||||
all already dev-deps). Re-create integration coverage for every route in §5,
|
||||
especially: public pages return 200, protected routes return 401/403 without auth,
|
||||
multipart uploads, and range requests on the streaming endpoints.
|
||||
|
||||
---
|
||||
|
||||
## 11. Notes / known quirks carried over
|
||||
|
||||
- Track `duration` is frequently `NULL` (upload never extracts it). The player
|
||||
reads duration from the `<audio>` element's metadata, so the seek bar works
|
||||
anyway — don't block on server-side duration extraction.
|
||||
- `Admin` and `SuperAdmin` have identical permission sets; only role-assignment
|
||||
endpoints are SuperAdmin-gated.
|
||||
- Image references (`featured_image_id`, `cover_image_id`) are plain strings, not
|
||||
foreign keys — the rewrite may upgrade these to real FKs if desired.
|
||||
4
assets/i18n/de-DE/main.ftl
Normal file
4
assets/i18n/de-DE/main.ftl
Normal file
@@ -0,0 +1,4 @@
|
||||
hello-world = Hallo Welt!
|
||||
greeting = Hallochen { $name }!
|
||||
.placeholder = Hallo Freund!
|
||||
about = Uber
|
||||
10
assets/i18n/en-US/main.ftl
Normal file
10
assets/i18n/en-US/main.ftl
Normal file
@@ -0,0 +1,10 @@
|
||||
hello-world = Hello World!
|
||||
greeting = Hello { $name }!
|
||||
.placeholder = Hello Friend!
|
||||
about = About
|
||||
simple = simple text
|
||||
reference = simple text with a reference: { -something }
|
||||
parameter = text with a { $param }
|
||||
parameter2 = text one { $param } second { $multi-word-param }
|
||||
email = text with an EMAIL("example@example.org")
|
||||
fallback = this should fall back
|
||||
1
assets/i18n/shared.ftl
Normal file
1
assets/i18n/shared.ftl
Normal file
@@ -0,0 +1 @@
|
||||
-something = foo
|
||||
3
assets/static/404.html
Normal file
3
assets/static/404.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<html><body>
|
||||
not found :-(
|
||||
</body></html>
|
||||
BIN
assets/static/image.png
Normal file
BIN
assets/static/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
12
assets/views/home/hello.html
Normal file
12
assets/views/home/hello.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<html><body>
|
||||
<img src="/static/image.png" width="200"/>
|
||||
<br/>
|
||||
find this tera template at <code>assets/views/home/hello.html</code>:
|
||||
<br/>
|
||||
<br/>
|
||||
{{ t(key="hello-world", lang="en-US") }},
|
||||
<br/>
|
||||
{{ t(key="hello-world", lang="de-DE") }}
|
||||
|
||||
</body></html>
|
||||
|
||||
99
config/development.yaml
Normal file
99
config/development.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
# Loco configuration file documentation
|
||||
|
||||
# Application logging configuration
|
||||
logger:
|
||||
# Enable or disable logging.
|
||||
enable: true
|
||||
# Enable pretty backtrace (sets RUST_BACKTRACE=1)
|
||||
pretty_backtrace: true
|
||||
# Log level, options: trace, debug, info, warn or error.
|
||||
level: debug
|
||||
# Define the logging format. options: compact, pretty or json
|
||||
format: compact
|
||||
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
|
||||
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
|
||||
# override_filter: trace
|
||||
|
||||
# Web server configuration
|
||||
server:
|
||||
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
|
||||
port: 5150
|
||||
# Binding for the server (which interface to bind to)
|
||||
binding: localhost
|
||||
# The UI hostname or IP address that mailers will point to.
|
||||
host: http://localhost
|
||||
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
|
||||
middlewares:
|
||||
static:
|
||||
enable: true
|
||||
must_exist: true
|
||||
precompressed: false
|
||||
folder:
|
||||
uri: "/static"
|
||||
path: "assets/static"
|
||||
fallback: "assets/static/404.html"
|
||||
|
||||
# Worker Configuration
|
||||
workers:
|
||||
# specifies the worker mode. Options:
|
||||
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
|
||||
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
|
||||
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
|
||||
mode: BackgroundAsync
|
||||
|
||||
|
||||
|
||||
# Mailer Configuration.
|
||||
mailer:
|
||||
# SMTP mailer configuration.
|
||||
smtp:
|
||||
# Enable/Disable smtp mailer.
|
||||
enable: true
|
||||
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||
host: localhost
|
||||
# SMTP server port
|
||||
port: 1025
|
||||
# Use secure connection (SSL/TLS).
|
||||
secure: false
|
||||
# auth:
|
||||
# user:
|
||||
# password:
|
||||
# Override the SMTP hello name (default is the machine's hostname)
|
||||
# hello_name:
|
||||
|
||||
# Initializers Configuration
|
||||
# initializers:
|
||||
# oauth2:
|
||||
# authorization_code: # Authorization code grant type
|
||||
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
|
||||
# ... other fields
|
||||
|
||||
# Database Configuration
|
||||
database:
|
||||
# Database connection URI
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://loco:loco@localhost:5432/universal_web_development") }}
|
||||
# When enabled, the sql query will be logged.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
|
||||
# Set the idle duration before closing a connection.
|
||||
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
|
||||
# Minimum number of connections for a pool.
|
||||
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
|
||||
# Maximum number of connections for a pool.
|
||||
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }}
|
||||
# Run migration up when application loaded
|
||||
auto_migrate: true
|
||||
# Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||
dangerously_truncate: false
|
||||
# Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||
dangerously_recreate: false
|
||||
|
||||
# Authentication Configuration
|
||||
auth:
|
||||
# JWT authentication
|
||||
jwt:
|
||||
# Secret key for token generation and verification
|
||||
secret: A6ECni63rt2Jb00tX9Hf
|
||||
# Token expiration time in seconds
|
||||
expiration: 604800 # 7 days
|
||||
96
config/test.yaml
Normal file
96
config/test.yaml
Normal file
@@ -0,0 +1,96 @@
|
||||
# Loco configuration file documentation
|
||||
|
||||
# Application logging configuration
|
||||
logger:
|
||||
# Enable or disable logging.
|
||||
enable: false
|
||||
# Enable pretty backtrace (sets RUST_BACKTRACE=1)
|
||||
pretty_backtrace: true
|
||||
# Log level, options: trace, debug, info, warn or error.
|
||||
level: debug
|
||||
# Define the logging format. options: compact, pretty or json
|
||||
format: compact
|
||||
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
|
||||
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
|
||||
# override_filter: trace
|
||||
|
||||
# Web server configuration
|
||||
server:
|
||||
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
|
||||
port: 5150
|
||||
# The UI hostname or IP address that mailers will point to.
|
||||
host: http://localhost
|
||||
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
|
||||
middlewares:
|
||||
static:
|
||||
enable: true
|
||||
must_exist: true
|
||||
precompressed: false
|
||||
folder:
|
||||
uri: "/static"
|
||||
path: "assets/static"
|
||||
fallback: "assets/static/404.html"
|
||||
|
||||
# Worker Configuration
|
||||
workers:
|
||||
# specifies the worker mode. Options:
|
||||
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
|
||||
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
|
||||
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
|
||||
mode: ForegroundBlocking
|
||||
|
||||
|
||||
|
||||
# Mailer Configuration.
|
||||
mailer:
|
||||
stub: true
|
||||
# SMTP mailer configuration.
|
||||
smtp:
|
||||
# Enable/Disable smtp mailer.
|
||||
enable: true
|
||||
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||
host: localhost
|
||||
# SMTP server port
|
||||
port: 1025
|
||||
# Use secure connection (SSL/TLS).
|
||||
secure: false
|
||||
# auth:
|
||||
# user:
|
||||
# password:
|
||||
|
||||
# Initializers Configuration
|
||||
# initializers:
|
||||
# oauth2:
|
||||
# authorization_code: # Authorization code grant type
|
||||
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
|
||||
# ... other fields
|
||||
|
||||
# Database Configuration
|
||||
database:
|
||||
# Database connection URI
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://loco:loco@localhost:5432/universal_web_test") }}
|
||||
# When enabled, the sql query will be logged.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
|
||||
# Set the idle duration before closing a connection.
|
||||
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
|
||||
# Minimum number of connections for a pool.
|
||||
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
|
||||
# Maximum number of connections for a pool.
|
||||
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }}
|
||||
# Run migration up when application loaded
|
||||
auto_migrate: true
|
||||
# Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||
dangerously_truncate: true
|
||||
# Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||
dangerously_recreate: true
|
||||
|
||||
# Authentication Configuration
|
||||
auth:
|
||||
# JWT authentication
|
||||
jwt:
|
||||
# Secret key for token generation and verification
|
||||
secret: 0yWwoflcGiAhonIzhQyQ
|
||||
# Token expiration time in seconds
|
||||
expiration: 604800 # 7 days
|
||||
21
examples/playground.rs
Normal file
21
examples/playground.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
#[allow(unused_imports)]
|
||||
use loco_rs::{cli::playground, prelude::*};
|
||||
use universal_web::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
let _ctx = playground::<App>().await?;
|
||||
|
||||
// let active_model: articles::ActiveModel = articles::ActiveModel {
|
||||
// title: Set(Some("how to build apps in 3 steps".to_string())),
|
||||
// content: Set(Some("use Loco: https://loco.rs".to_string())),
|
||||
// ..Default::default()
|
||||
// };
|
||||
// active_model.insert(&ctx.db).await.unwrap();
|
||||
|
||||
// let res = articles::Entity::find().all(&ctx.db).await.unwrap();
|
||||
// println!("{:?}", res);
|
||||
println!("welcome to playground. edit me at `examples/playground.rs`");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
22
migration/Cargo.toml
Normal file
22
migration/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "migration"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
loco-rs = { workspace = true }
|
||||
|
||||
|
||||
[dependencies.sea-orm-migration]
|
||||
version = "1.1.0"
|
||||
features = [
|
||||
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
|
||||
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
|
||||
# e.g.
|
||||
"runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
|
||||
]
|
||||
16
migration/src/lib.rs
Normal file
16
migration/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
#![allow(elided_lifetimes_in_paths)]
|
||||
#![allow(clippy::wildcard_imports)]
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
mod m20220101_000001_users;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
Box::new(m20220101_000001_users::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
}
|
||||
41
migration/src/m20220101_000001_users.rs
Normal file
41
migration/src/m20220101_000001_users.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use loco_rs::schema::*;
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
create_table(
|
||||
m,
|
||||
"users",
|
||||
&[
|
||||
("id", ColType::PkAuto),
|
||||
("pid", ColType::Uuid),
|
||||
("email", ColType::StringUniq),
|
||||
("password", ColType::String),
|
||||
("api_key", ColType::StringUniq),
|
||||
("name", ColType::String),
|
||||
("reset_token", ColType::StringNull),
|
||||
("reset_sent_at", ColType::TimestampWithTimeZoneNull),
|
||||
("email_verification_token", ColType::StringNull),
|
||||
(
|
||||
"email_verification_sent_at",
|
||||
ColType::TimestampWithTimeZoneNull,
|
||||
),
|
||||
("email_verified_at", ColType::TimestampWithTimeZoneNull),
|
||||
("magic_link_token", ColType::StringNull),
|
||||
("magic_link_expiration", ColType::TimestampWithTimeZoneNull),
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
drop_table(m, "users").await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
74
src/app.rs
Normal file
74
src/app.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use async_trait::async_trait;
|
||||
use loco_rs::{
|
||||
app::{AppContext, Hooks, Initializer},
|
||||
bgworker::{BackgroundWorker, Queue},
|
||||
boot::{create_app, BootResult, StartMode},
|
||||
config::Config,
|
||||
controller::AppRoutes,
|
||||
db::{self, truncate_table},
|
||||
environment::Environment,
|
||||
task::Tasks,
|
||||
Result,
|
||||
};
|
||||
use migration::Migrator;
|
||||
use std::path::Path;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::{
|
||||
controllers, initializers, models::_entities::users, tasks, workers::downloader::DownloadWorker,
|
||||
};
|
||||
|
||||
pub struct App;
|
||||
#[async_trait]
|
||||
impl Hooks for App {
|
||||
fn app_name() -> &'static str {
|
||||
env!("CARGO_CRATE_NAME")
|
||||
}
|
||||
|
||||
fn app_version() -> String {
|
||||
format!(
|
||||
"{} ({})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
option_env!("BUILD_SHA")
|
||||
.or(option_env!("GITHUB_SHA"))
|
||||
.unwrap_or("dev")
|
||||
)
|
||||
}
|
||||
|
||||
async fn boot(
|
||||
mode: StartMode,
|
||||
environment: &Environment,
|
||||
config: Config,
|
||||
) -> Result<BootResult> {
|
||||
create_app::<Self, Migrator>(mode, environment, config).await
|
||||
}
|
||||
|
||||
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
|
||||
Ok(vec![Box::new(
|
||||
initializers::view_engine::ViewEngineInitializer,
|
||||
)])
|
||||
}
|
||||
|
||||
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||
AppRoutes::with_default_routes() // controller routes below
|
||||
.add_route(controllers::auth::routes())
|
||||
}
|
||||
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||
queue.register(DownloadWorker::build(ctx)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
fn register_tasks(tasks: &mut Tasks) {
|
||||
// tasks-inject (do not remove)
|
||||
}
|
||||
async fn truncate(ctx: &AppContext) -> Result<()> {
|
||||
truncate_table(&ctx.db, users::Entity).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
8
src/bin/main.rs
Normal file
8
src/bin/main.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use loco_rs::cli;
|
||||
use migration::Migrator;
|
||||
use universal_web::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
cli::main::<App, Migrator>().await
|
||||
}
|
||||
273
src/controllers/auth.rs
Normal file
273
src/controllers/auth.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
use crate::{
|
||||
mailers::auth::AuthMailer,
|
||||
models::{
|
||||
_entities::users,
|
||||
users::{LoginParams, RegisterParams},
|
||||
},
|
||||
views::auth::{CurrentResponse, LoginResponse},
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
|
||||
|
||||
fn get_allow_email_domain_re() -> &'static Regex {
|
||||
EMAIL_DOMAIN_RE.get_or_init(|| {
|
||||
Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex")
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ForgotParams {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ResetParams {
|
||||
pub token: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct MagicLinkParams {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ResendVerificationParams {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Register function creates a new user with the given parameters and sends a
|
||||
/// welcome email to the user
|
||||
#[debug_handler]
|
||||
async fn register(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<RegisterParams>,
|
||||
) -> Result<Response> {
|
||||
let res = users::Model::create_with_password(&ctx.db, ¶ms).await;
|
||||
|
||||
let user = match res {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
tracing::info!(
|
||||
message = err.to_string(),
|
||||
user_email = ¶ms.email,
|
||||
"could not register user",
|
||||
);
|
||||
return format::json(());
|
||||
}
|
||||
};
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_email_verification_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// Verify register user. if the user not verified his email, he can't login to
|
||||
/// the system.
|
||||
#[debug_handler]
|
||||
async fn verify(State(ctx): State<AppContext>, Path(token): Path<String>) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else {
|
||||
return unauthorized("invalid token");
|
||||
};
|
||||
|
||||
if user.email_verified_at.is_some() {
|
||||
tracing::info!(pid = user.pid.to_string(), "user already verified");
|
||||
} else {
|
||||
let active_model = user.into_active_model();
|
||||
let user = active_model.verified(&ctx.db).await?;
|
||||
tracing::info!(pid = user.pid.to_string(), "user verified");
|
||||
}
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// In case the user forgot his password this endpoints generate a forgot token
|
||||
/// and send email to the user. In case the email not found in our DB, we are
|
||||
/// returning a valid request for for security reasons (not exposing users DB
|
||||
/// list).
|
||||
#[debug_handler]
|
||||
async fn forgot(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<ForgotParams>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
return format::json(());
|
||||
};
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_forgot_password_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::forgot_password(&ctx, &user).await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// reset user password by the given parameters
|
||||
#[debug_handler]
|
||||
async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
tracing::info!("reset token not found");
|
||||
|
||||
return format::json(());
|
||||
};
|
||||
user.into_active_model()
|
||||
.reset_password(&ctx.db, ¶ms.password)
|
||||
.await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// Creates a user login and returns a token
|
||||
#[debug_handler]
|
||||
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
tracing::debug!(
|
||||
email = params.email,
|
||||
"login attempt with non-existent email"
|
||||
);
|
||||
return unauthorized("Invalid credentials!");
|
||||
};
|
||||
|
||||
let valid = user.verify_password(¶ms.password);
|
||||
|
||||
if !valid {
|
||||
return unauthorized("unauthorized!");
|
||||
}
|
||||
|
||||
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||
|
||||
let token = user
|
||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::json(LoginResponse::new(&user, &token))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
format::json(CurrentResponse::new(&user))
|
||||
}
|
||||
|
||||
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
||||
///
|
||||
/// # Flow
|
||||
/// 1. **Request a Magic Link**:
|
||||
/// A registered user sends a POST request to `/magic-link` with their email.
|
||||
/// If the email exists, a short-lived, one-time-use token is generated and sent to the user's email.
|
||||
/// For security and to avoid exposing whether an email exists, the response always returns 200, even if the email is invalid.
|
||||
///
|
||||
/// 2. **Click the Magic Link**:
|
||||
/// The user clicks the link (/magic-link/{token}), which validates the token and its expiration.
|
||||
/// If valid, the server generates a JWT and responds with a [`LoginResponse`].
|
||||
/// If invalid or expired, an unauthorized response is returned.
|
||||
///
|
||||
/// This flow enhances security by avoiding traditional passwords and providing a seamless login experience.
|
||||
async fn magic_link(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<MagicLinkParams>,
|
||||
) -> Result<Response> {
|
||||
let email_regex = get_allow_email_domain_re();
|
||||
if !email_regex.is_match(¶ms.email) {
|
||||
tracing::debug!(
|
||||
email = params.email,
|
||||
"The provided email is invalid or does not match the allowed domains"
|
||||
);
|
||||
return bad_request("invalid request");
|
||||
}
|
||||
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
tracing::debug!(email = params.email, "user not found by email");
|
||||
return format::empty_json();
|
||||
};
|
||||
|
||||
let user = user.into_active_model().create_magic_link(&ctx.db).await?;
|
||||
AuthMailer::send_magic_link(&ctx, &user).await?;
|
||||
|
||||
format::empty_json()
|
||||
}
|
||||
|
||||
/// Verifies a magic link token and authenticates the user.
|
||||
async fn magic_link_verify(
|
||||
Path(token): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
return unauthorized("unauthorized!");
|
||||
};
|
||||
|
||||
let user = user.into_active_model().clear_magic_link(&ctx.db).await?;
|
||||
|
||||
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||
|
||||
let token = user
|
||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::json(LoginResponse::new(&user, &token))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn resend_verification_email(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<ResendVerificationParams>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
tracing::info!(
|
||||
email = params.email,
|
||||
"User not found for resend verification"
|
||||
);
|
||||
return format::json(());
|
||||
};
|
||||
|
||||
if user.email_verified_at.is_some() {
|
||||
tracing::info!(
|
||||
pid = user.pid.to_string(),
|
||||
"User already verified, skipping resend"
|
||||
);
|
||||
return format::json(());
|
||||
}
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_email_verification_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||
tracing::info!(pid = user.pid.to_string(), "Verification email re-sent");
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/auth")
|
||||
.add("/register", post(register))
|
||||
.add("/verify/{token}", get(verify))
|
||||
.add("/login", post(login))
|
||||
.add("/forgot", post(forgot))
|
||||
.add("/reset", post(reset))
|
||||
.add("/current", get(current))
|
||||
.add("/magic-link", post(magic_link))
|
||||
.add("/magic-link/{token}", get(magic_link_verify))
|
||||
.add("/resend-verification-mail", post(resend_verification_email))
|
||||
}
|
||||
1
src/controllers/mod.rs
Normal file
1
src/controllers/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
1
src/data/mod.rs
Normal file
1
src/data/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
17
src/fixtures/users.yaml
Normal file
17
src/fixtures/users.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
- id: 1
|
||||
pid: 11111111-1111-1111-1111-111111111111
|
||||
email: user1@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
||||
name: user1
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
||||
- id: 2
|
||||
pid: 22222222-2222-2222-2222-222222222222
|
||||
email: user2@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
||||
name: user2
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
||||
1
src/initializers/mod.rs
Normal file
1
src/initializers/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod view_engine;
|
||||
43
src/initializers/view_engine.rs
Normal file
43
src/initializers/view_engine.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use async_trait::async_trait;
|
||||
use axum::{Extension, Router as AxumRouter};
|
||||
use fluent_templates::{ArcLoader, FluentLoader};
|
||||
use loco_rs::{
|
||||
app::{AppContext, Initializer},
|
||||
controller::views::{engines, ViewEngine},
|
||||
Error, Result,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
const I18N_DIR: &str = "assets/i18n";
|
||||
const I18N_SHARED: &str = "assets/i18n/shared.ftl";
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct ViewEngineInitializer;
|
||||
|
||||
#[async_trait]
|
||||
impl Initializer for ViewEngineInitializer {
|
||||
fn name(&self) -> String {
|
||||
"view-engine".to_string()
|
||||
}
|
||||
|
||||
async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||
let tera_engine = if std::path::Path::new(I18N_DIR).exists() {
|
||||
let arc = std::sync::Arc::new(
|
||||
ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US"))
|
||||
.shared_resources(Some(&[I18N_SHARED.into()]))
|
||||
.customize(|bundle| bundle.set_use_isolating(false))
|
||||
.build()
|
||||
.map_err(|e| Error::string(&e.to_string()))?,
|
||||
);
|
||||
info!("locales loaded");
|
||||
|
||||
engines::TeraView::build()?.post_process(move |tera| {
|
||||
tera.register_function("t", FluentLoader::new(arc.clone()));
|
||||
Ok(())
|
||||
})?
|
||||
} else {
|
||||
engines::TeraView::build()?
|
||||
};
|
||||
|
||||
Ok(router.layer(Extension(ViewEngine::from(tera_engine))))
|
||||
}
|
||||
}
|
||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod app;
|
||||
pub mod controllers;
|
||||
pub mod data;
|
||||
pub mod initializers;
|
||||
pub mod mailers;
|
||||
pub mod models;
|
||||
pub mod tasks;
|
||||
pub mod views;
|
||||
pub mod workers;
|
||||
90
src/mailers/auth.rs
Normal file
90
src/mailers/auth.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
// auth mailer
|
||||
#![allow(non_upper_case_globals)]
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::models::users;
|
||||
|
||||
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
||||
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
||||
static magic_link: Dir<'_> = include_dir!("src/mailers/auth/magic_link");
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct AuthMailer {}
|
||||
impl Mailer for AuthMailer {}
|
||||
impl AuthMailer {
|
||||
/// Sending welcome email the the given user
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When email sending is failed
|
||||
pub async fn send_welcome(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||
Self::mail_template(
|
||||
ctx,
|
||||
&welcome,
|
||||
mailer::Args {
|
||||
to: user.email.to_string(),
|
||||
locals: json!({
|
||||
"name": user.name,
|
||||
"verifyToken": user.email_verification_token,
|
||||
"domain": ctx.config.server.full_url()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sending forgot password email
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When email sending is failed
|
||||
pub async fn forgot_password(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||
Self::mail_template(
|
||||
ctx,
|
||||
&forgot,
|
||||
mailer::Args {
|
||||
to: user.email.to_string(),
|
||||
locals: json!({
|
||||
"name": user.name,
|
||||
"resetToken": user.reset_token,
|
||||
"domain": ctx.config.server.full_url()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a magic link authentication email to the user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When email sending is failed
|
||||
pub async fn send_magic_link(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||
Self::mail_template(
|
||||
ctx,
|
||||
&magic_link,
|
||||
mailer::Args {
|
||||
to: user.email.to_string(),
|
||||
locals: json!({
|
||||
"name": user.name,
|
||||
"token": user.magic_link_token.clone().ok_or_else(|| Error::string(
|
||||
"the user model not contains magic link token",
|
||||
))?,
|
||||
"host": ctx.config.server.full_url()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
11
src/mailers/auth/forgot/html.t
Normal file
11
src/mailers/auth/forgot/html.t
Normal file
@@ -0,0 +1,11 @@
|
||||
;<html>
|
||||
|
||||
<body>
|
||||
Hey {{name}},
|
||||
Forgot your password? No worries! You can reset it by clicking the link below:
|
||||
<a href="http://{{domain}}/reset#{{resetToken}}">Reset Your Password</a>
|
||||
If you didn't request a password reset, please ignore this email.
|
||||
Best regards,<br>The Loco Team</br>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1
src/mailers/auth/forgot/subject.t
Normal file
1
src/mailers/auth/forgot/subject.t
Normal file
@@ -0,0 +1 @@
|
||||
Your reset password link
|
||||
3
src/mailers/auth/forgot/text.t
Normal file
3
src/mailers/auth/forgot/text.t
Normal file
@@ -0,0 +1,3 @@
|
||||
Reset your password with this link:
|
||||
|
||||
http://localhost/reset#{{resetToken}}
|
||||
8
src/mailers/auth/magic_link/html.t
Normal file
8
src/mailers/auth/magic_link/html.t
Normal file
@@ -0,0 +1,8 @@
|
||||
;<html>
|
||||
<body>
|
||||
<p>Magic link example:</p>
|
||||
<a href="{{host}}/api/auth/magic-link/{{token}}">
|
||||
Verify Your Account
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
1
src/mailers/auth/magic_link/subject.t
Normal file
1
src/mailers/auth/magic_link/subject.t
Normal file
@@ -0,0 +1 @@
|
||||
Magic link example
|
||||
2
src/mailers/auth/magic_link/text.t
Normal file
2
src/mailers/auth/magic_link/text.t
Normal file
@@ -0,0 +1,2 @@
|
||||
Magic link with this link:
|
||||
{{host}}/api/auth/magic-link/{{token}}
|
||||
13
src/mailers/auth/welcome/html.t
Normal file
13
src/mailers/auth/welcome/html.t
Normal file
@@ -0,0 +1,13 @@
|
||||
;<html>
|
||||
|
||||
<body>
|
||||
Dear {{name}},
|
||||
Welcome to Loco! You can now log in to your account.
|
||||
Before you get started, please verify your account by clicking the link below:
|
||||
<a href="{{domain}}/api/auth/verify/{{verifyToken}}">
|
||||
Verify Your Account
|
||||
</a>
|
||||
<p>Best regards,<br>The Loco Team</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1
src/mailers/auth/welcome/subject.t
Normal file
1
src/mailers/auth/welcome/subject.t
Normal file
@@ -0,0 +1 @@
|
||||
Welcome {{name}}
|
||||
4
src/mailers/auth/welcome/text.t
Normal file
4
src/mailers/auth/welcome/text.t
Normal file
@@ -0,0 +1,4 @@
|
||||
Welcome {{name}}, you can now log in.
|
||||
Verify your account with the link below:
|
||||
|
||||
{{domain}}/api/auth/verify/{{verifyToken}}
|
||||
1
src/mailers/mod.rs
Normal file
1
src/mailers/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
4
src/models/_entities/mod.rs
Normal file
4
src/models/_entities/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
|
||||
pub mod prelude;
|
||||
pub mod users;
|
||||
2
src/models/_entities/prelude.rs
Normal file
2
src/models/_entities/prelude.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
pub use super::users::Entity as Users;
|
||||
30
src/models/_entities/users.rs
Normal file
30
src/models/_entities/users.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub pid: Uuid,
|
||||
#[sea_orm(unique)]
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
#[sea_orm(unique)]
|
||||
pub api_key: String,
|
||||
pub name: String,
|
||||
pub reset_token: Option<String>,
|
||||
pub reset_sent_at: Option<DateTimeWithTimeZone>,
|
||||
pub email_verification_token: Option<String>,
|
||||
pub email_verification_sent_at: Option<DateTimeWithTimeZone>,
|
||||
pub email_verified_at: Option<DateTimeWithTimeZone>,
|
||||
pub magic_link_token: Option<String>,
|
||||
pub magic_link_expiration: Option<DateTimeWithTimeZone>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
2
src/models/mod.rs
Normal file
2
src/models/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod _entities;
|
||||
pub mod users;
|
||||
369
src/models/users.rs
Normal file
369
src/models/users.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{offset::Local, Duration};
|
||||
use loco_rs::{auth::jwt, hash, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Map;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use super::_entities::users::{self, ActiveModel, Entity, Model};
|
||||
|
||||
pub const MAGIC_LINK_LENGTH: i8 = 32;
|
||||
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct LoginParams {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RegisterParams {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct Validator {
|
||||
#[validate(length(min = 2, message = "Name must be at least 2 characters long."))]
|
||||
pub name: String,
|
||||
#[validate(email(message = "invalid email"))]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl Validatable for ActiveModel {
|
||||
fn validator(&self) -> Box<dyn Validate> {
|
||||
Box::new(Validator {
|
||||
name: self.name.as_ref().to_owned(),
|
||||
email: self.email.as_ref().to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for super::_entities::users::ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
self.validate()?;
|
||||
if insert {
|
||||
let mut this = self;
|
||||
this.pid = ActiveValue::Set(Uuid::new_v4());
|
||||
this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4()));
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Authenticable for Model {
|
||||
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::ApiKey, api_key)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self> {
|
||||
Self::find_by_pid(db, claims_key).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// finds a user by the provided email
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::Email, email)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the provided verification token
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_verification_token(
|
||||
db: &DatabaseConnection,
|
||||
token: &str,
|
||||
) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::EmailVerificationToken, token)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the magic token and verify and token expiration
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error ot token expired
|
||||
pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
query::condition()
|
||||
.eq(users::Column::MagicLinkToken, token)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
let user = user.ok_or_else(|| ModelError::EntityNotFound)?;
|
||||
if let Some(expired_at) = user.magic_link_expiration {
|
||||
if expired_at >= Local::now() {
|
||||
Ok(user)
|
||||
} else {
|
||||
tracing::debug!(
|
||||
user_pid = user.pid.to_string(),
|
||||
token_expiration = expired_at.to_string(),
|
||||
"magic token expired for the user."
|
||||
);
|
||||
Err(ModelError::msg("magic token expired"))
|
||||
}
|
||||
} else {
|
||||
tracing::error!(
|
||||
user_pid = user.pid.to_string(),
|
||||
"magic link expiration time not exists"
|
||||
);
|
||||
Err(ModelError::msg("expiration token not exists"))
|
||||
}
|
||||
}
|
||||
|
||||
/// finds a user by the provided reset token
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_reset_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::ResetToken, token)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the provided pid
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user or DB query error
|
||||
pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult<Self> {
|
||||
let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Any(e.into()))?;
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::Pid, parse_uuid)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the provided api key
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::ApiKey, api_key)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// Verifies whether the provided plain password matches the hashed password
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when could not verify password
|
||||
#[must_use]
|
||||
pub fn verify_password(&self, password: &str) -> bool {
|
||||
hash::verify_password(password, &self.password)
|
||||
}
|
||||
|
||||
/// Asynchronously creates a user with a password and saves it to the
|
||||
/// database.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not save the user into the DB
|
||||
pub async fn create_with_password(
|
||||
db: &DatabaseConnection,
|
||||
params: &RegisterParams,
|
||||
) -> ModelResult<Self> {
|
||||
let txn = db.begin().await?;
|
||||
|
||||
if users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::Email, ¶ms.email)
|
||||
.build(),
|
||||
)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ModelError::EntityAlreadyExists {});
|
||||
}
|
||||
|
||||
let password_hash =
|
||||
hash::hash_password(¶ms.password).map_err(|e| ModelError::Any(e.into()))?;
|
||||
let user = users::ActiveModel {
|
||||
email: ActiveValue::set(params.email.to_string()),
|
||||
password: ActiveValue::set(password_hash),
|
||||
name: ActiveValue::set(params.name.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Creates a JWT
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when could not convert user claims to jwt token
|
||||
pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult<String> {
|
||||
jwt::JWT::new(secret)
|
||||
.generate_token(expiration, self.pid.to_string(), Map::new())
|
||||
.map_err(ModelError::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModel {
|
||||
/// Sets the email verification information for the user and
|
||||
/// updates it in the database.
|
||||
///
|
||||
/// This method is used to record the timestamp when the email verification
|
||||
/// was sent and generate a unique verification token for the user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error
|
||||
pub async fn set_email_verification_sent(
|
||||
mut self,
|
||||
db: &DatabaseConnection,
|
||||
) -> ModelResult<Model> {
|
||||
self.email_verification_sent_at = ActiveValue::set(Some(Local::now().into()));
|
||||
self.email_verification_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
|
||||
/// Sets the information for a reset password request,
|
||||
/// generates a unique reset password token, and updates it in the
|
||||
/// database.
|
||||
///
|
||||
/// This method records the timestamp when the reset password token is sent
|
||||
/// and generates a unique token for the user.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error
|
||||
pub async fn set_forgot_password_sent(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||
self.reset_sent_at = ActiveValue::set(Some(Local::now().into()));
|
||||
self.reset_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
|
||||
/// Records the verification time when a user verifies their
|
||||
/// email and updates it in the database.
|
||||
///
|
||||
/// This method sets the timestamp when the user successfully verifies their
|
||||
/// email.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error
|
||||
pub async fn verified(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||
self.email_verified_at = ActiveValue::set(Some(Local::now().into()));
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
|
||||
/// Resets the current user password with a new password and
|
||||
/// updates it in the database.
|
||||
///
|
||||
/// This method hashes the provided password and sets it as the new password
|
||||
/// for the user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error or could not hashed the given password
|
||||
pub async fn reset_password(
|
||||
mut self,
|
||||
db: &DatabaseConnection,
|
||||
password: &str,
|
||||
) -> ModelResult<Model> {
|
||||
self.password =
|
||||
ActiveValue::set(hash::hash_password(password).map_err(|e| ModelError::Any(e.into()))?);
|
||||
self.reset_token = ActiveValue::Set(None);
|
||||
self.reset_sent_at = ActiveValue::Set(None);
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
|
||||
/// Creates a magic link token for passwordless authentication.
|
||||
///
|
||||
/// Generates a random token with a specified length and sets an expiration time
|
||||
/// for the magic link. This method is used to initiate the magic link authentication flow.
|
||||
///
|
||||
/// # Errors
|
||||
/// - Returns an error if database update fails
|
||||
pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||
let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize);
|
||||
let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into());
|
||||
|
||||
self.magic_link_token = ActiveValue::set(Some(random_str));
|
||||
self.magic_link_expiration = ActiveValue::set(Some(expired.into()));
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
|
||||
/// Verifies and invalidates the magic link after successful authentication.
|
||||
///
|
||||
/// Clears the magic link token and expiration time after the user has
|
||||
/// successfully authenticated using the magic link.
|
||||
///
|
||||
/// # Errors
|
||||
/// - Returns an error if database update fails
|
||||
pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||
self.magic_link_token = ActiveValue::set(None);
|
||||
self.magic_link_expiration = ActiveValue::set(None);
|
||||
self.update(db).await.map_err(ModelError::from)
|
||||
}
|
||||
}
|
||||
1
src/tasks/mod.rs
Normal file
1
src/tasks/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
41
src/views/auth.rs
Normal file
41
src/views/auth.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::users;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub pid: String,
|
||||
pub name: String,
|
||||
pub is_verified: bool,
|
||||
}
|
||||
|
||||
impl LoginResponse {
|
||||
#[must_use]
|
||||
pub fn new(user: &users::Model, token: &String) -> Self {
|
||||
Self {
|
||||
token: token.to_string(),
|
||||
pid: user.pid.to_string(),
|
||||
name: user.name.clone(),
|
||||
is_verified: user.email_verified_at.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CurrentResponse {
|
||||
pub pid: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl CurrentResponse {
|
||||
#[must_use]
|
||||
pub fn new(user: &users::Model) -> Self {
|
||||
Self {
|
||||
pid: user.pid.to_string(),
|
||||
name: user.name.clone(),
|
||||
email: user.email.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/views/mod.rs
Normal file
1
src/views/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
23
src/workers/downloader.rs
Normal file
23
src/workers/downloader.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct DownloadWorker {
|
||||
pub ctx: AppContext,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Serialize)]
|
||||
pub struct DownloadWorkerArgs {
|
||||
pub user_guid: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackgroundWorker<DownloadWorkerArgs> for DownloadWorker {
|
||||
fn build(ctx: &AppContext) -> Self {
|
||||
Self { ctx: ctx.clone() }
|
||||
}
|
||||
async fn perform(&self, _args: DownloadWorkerArgs) -> Result<()> {
|
||||
// TODO: Some actual work goes here...
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1
src/workers/mod.rs
Normal file
1
src/workers/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod downloader;
|
||||
4
tests/mod.rs
Normal file
4
tests/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod models;
|
||||
mod requests;
|
||||
mod tasks;
|
||||
mod workers;
|
||||
1
tests/models/mod.rs
Normal file
1
tests/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
mod users;
|
||||
23
tests/models/snapshots/can_create_with_password@users.snap
Normal file
23
tests/models/snapshots/can_create_with_password@users.snap
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: res
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: DATE,
|
||||
updated_at: DATE,
|
||||
id: ID
|
||||
pid: PID,
|
||||
email: "test@framework.com",
|
||||
password: "PASSWORD",
|
||||
api_key: "lo-PID",
|
||||
name: "framework",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: None,
|
||||
email_verification_sent_at: None,
|
||||
email_verified_at: None,
|
||||
magic_link_token: None,
|
||||
magic_link_expiration: None,
|
||||
},
|
||||
)
|
||||
7
tests/models/snapshots/can_find_by_email@users-2.snap
Normal file
7
tests/models/snapshots/can_find_by_email@users-2.snap
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: non_existing_user_results
|
||||
---
|
||||
Err(
|
||||
EntityNotFound,
|
||||
)
|
||||
23
tests/models/snapshots/can_find_by_email@users.snap
Normal file
23
tests/models/snapshots/can_find_by_email@users.snap
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: existing_user
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: 2023-11-12T12:34:56.789+00:00,
|
||||
updated_at: 2023-11-12T12:34:56.789+00:00,
|
||||
id: 1,
|
||||
pid: 11111111-1111-1111-1111-111111111111,
|
||||
email: "user1@example.com",
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
|
||||
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
|
||||
name: "user1",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: None,
|
||||
email_verification_sent_at: None,
|
||||
email_verified_at: None,
|
||||
magic_link_token: None,
|
||||
magic_link_expiration: None,
|
||||
},
|
||||
)
|
||||
7
tests/models/snapshots/can_find_by_pid@users-2.snap
Normal file
7
tests/models/snapshots/can_find_by_pid@users-2.snap
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: non_existing_user_results
|
||||
---
|
||||
Err(
|
||||
EntityNotFound,
|
||||
)
|
||||
23
tests/models/snapshots/can_find_by_pid@users.snap
Normal file
23
tests/models/snapshots/can_find_by_pid@users.snap
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: existing_user
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: 2023-11-12T12:34:56.789+00:00,
|
||||
updated_at: 2023-11-12T12:34:56.789+00:00,
|
||||
id: 1,
|
||||
pid: 11111111-1111-1111-1111-111111111111,
|
||||
email: "user1@example.com",
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
|
||||
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
|
||||
name: "user1",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: None,
|
||||
email_verification_sent_at: None,
|
||||
email_verified_at: None,
|
||||
magic_link_token: None,
|
||||
magic_link_expiration: None,
|
||||
},
|
||||
)
|
||||
9
tests/models/snapshots/can_validate_model@users.snap
Normal file
9
tests/models/snapshots/can_validate_model@users.snap
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: res
|
||||
---
|
||||
Err(
|
||||
Custom(
|
||||
"{\"email\":[{\"code\":\"email\",\"message\":\"invalid email\"}],\"name\":[{\"code\":\"length\",\"message\":\"Name must be at least 2 characters long.\"}]}",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: new_user
|
||||
---
|
||||
Err(
|
||||
EntityAlreadyExists,
|
||||
)
|
||||
360
tests/models/users.rs
Normal file
360
tests/models/users.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
use chrono::{offset::Local, Duration};
|
||||
use insta::assert_debug_snapshot;
|
||||
use loco_rs::testing::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
||||
use serial_test::serial;
|
||||
use universal_web::{
|
||||
app::App,
|
||||
models::users::{self, Model, RegisterParams},
|
||||
};
|
||||
|
||||
macro_rules! configure_insta {
|
||||
($($expr:expr),*) => {
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.set_prepend_module_to_snapshot(false);
|
||||
settings.set_snapshot_suffix("users");
|
||||
let _guard = settings.bind_to_scope();
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_can_validate_model() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
|
||||
let invalid_user = users::ActiveModel {
|
||||
name: ActiveValue::set("1".to_string()),
|
||||
email: ActiveValue::set("invalid-email".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let res = invalid_user.insert(&boot.app_context.db).await;
|
||||
|
||||
assert_debug_snapshot!(res);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_create_with_password() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
|
||||
let params = RegisterParams {
|
||||
email: "test@framework.com".to_string(),
|
||||
password: "1234".to_string(),
|
||||
name: "framework".to_string(),
|
||||
};
|
||||
|
||||
let res = Model::create_with_password(&boot.app_context.db, ¶ms).await;
|
||||
|
||||
insta::with_settings!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(res);
|
||||
});
|
||||
}
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn handle_create_with_password_with_duplicate() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let new_user = Model::create_with_password(
|
||||
&boot.app_context.db,
|
||||
&RegisterParams {
|
||||
email: "user1@example.com".to_string(),
|
||||
password: "1234".to_string(),
|
||||
name: "framework".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_debug_snapshot!(new_user);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_find_by_email() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let existing_user = Model::find_by_email(&boot.app_context.db, "user1@example.com").await;
|
||||
let non_existing_user_results =
|
||||
Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await;
|
||||
|
||||
assert_debug_snapshot!(existing_user);
|
||||
assert_debug_snapshot!(non_existing_user_results);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_find_by_pid() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let existing_user =
|
||||
Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await;
|
||||
let non_existing_user_results =
|
||||
Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await;
|
||||
|
||||
assert_debug_snapshot!(existing_user);
|
||||
assert_debug_snapshot!(non_existing_user_results);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_verification_token() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID");
|
||||
|
||||
assert!(
|
||||
user.email_verification_sent_at.is_none(),
|
||||
"Expected no email verification sent timestamp"
|
||||
);
|
||||
assert!(
|
||||
user.email_verification_token.is_none(),
|
||||
"Expected no email verification token"
|
||||
);
|
||||
|
||||
let result = user
|
||||
.into_active_model()
|
||||
.set_email_verification_sent(&boot.app_context.db)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Failed to set email verification sent");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID after setting verification sent");
|
||||
|
||||
assert!(
|
||||
user.email_verification_sent_at.is_some(),
|
||||
"Expected email verification sent timestamp to be present"
|
||||
);
|
||||
assert!(
|
||||
user.email_verification_token.is_some(),
|
||||
"Expected email verification token to be present"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_set_forgot_password_sent() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID");
|
||||
|
||||
assert!(
|
||||
user.reset_sent_at.is_none(),
|
||||
"Expected no reset sent timestamp"
|
||||
);
|
||||
assert!(user.reset_token.is_none(), "Expected no reset token");
|
||||
|
||||
let result = user
|
||||
.into_active_model()
|
||||
.set_forgot_password_sent(&boot.app_context.db)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Failed to set forgot password sent");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID after setting forgot password sent");
|
||||
|
||||
assert!(
|
||||
user.reset_sent_at.is_some(),
|
||||
"Expected reset sent timestamp to be present"
|
||||
);
|
||||
assert!(
|
||||
user.reset_token.is_some(),
|
||||
"Expected reset token to be present"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_verified() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID");
|
||||
|
||||
assert!(
|
||||
user.email_verified_at.is_none(),
|
||||
"Expected email to be unverified"
|
||||
);
|
||||
|
||||
let result = user
|
||||
.into_active_model()
|
||||
.verified(&boot.app_context.db)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Failed to mark email as verified");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID after verification");
|
||||
|
||||
assert!(
|
||||
user.email_verified_at.is_some(),
|
||||
"Expected email to be verified"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_reset_password() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = boot_test::<App>()
|
||||
.await
|
||||
.expect("Failed to boot test application");
|
||||
seed::<App>(&boot.app_context)
|
||||
.await
|
||||
.expect("Failed to seed database");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID");
|
||||
|
||||
assert!(
|
||||
user.verify_password("12341234"),
|
||||
"Password verification failed for original password"
|
||||
);
|
||||
|
||||
let result = user
|
||||
.clone()
|
||||
.into_active_model()
|
||||
.reset_password(&boot.app_context.db, "new-password")
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Failed to reset password");
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to find user by PID after password reset");
|
||||
|
||||
assert!(
|
||||
user.verify_password("new-password"),
|
||||
"Password verification failed for new password"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn magic_link() {
|
||||
let boot = boot_test::<App>().await.unwrap();
|
||||
seed::<App>(&boot.app_context).await.unwrap();
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
user.magic_link_token.is_none(),
|
||||
"Magic link token should be initially unset"
|
||||
);
|
||||
assert!(
|
||||
user.magic_link_expiration.is_none(),
|
||||
"Magic link expiration should be initially unset"
|
||||
);
|
||||
|
||||
let create_result = user
|
||||
.into_active_model()
|
||||
.create_magic_link(&boot.app_context.db)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
create_result.is_ok(),
|
||||
"Failed to create magic link: {:?}",
|
||||
create_result.unwrap_err()
|
||||
);
|
||||
|
||||
let updated_user =
|
||||
Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.expect("Failed to refetch user after magic link creation");
|
||||
|
||||
assert!(
|
||||
updated_user.magic_link_token.is_some(),
|
||||
"Magic link token should be set after creation"
|
||||
);
|
||||
|
||||
let magic_link_token = updated_user.magic_link_token.unwrap();
|
||||
assert_eq!(
|
||||
magic_link_token.len(),
|
||||
users::MAGIC_LINK_LENGTH as usize,
|
||||
"Magic link token length does not match expected length"
|
||||
);
|
||||
|
||||
assert!(
|
||||
updated_user.magic_link_expiration.is_some(),
|
||||
"Magic link expiration should be set after creation"
|
||||
);
|
||||
|
||||
let now = Local::now();
|
||||
let should_expired_at = now + Duration::minutes(users::MAGIC_LINK_EXPIRATION_MIN.into());
|
||||
let actual_expiration = updated_user.magic_link_expiration.unwrap();
|
||||
|
||||
assert!(
|
||||
actual_expiration >= now,
|
||||
"Magic link expiration should be in the future or now"
|
||||
);
|
||||
|
||||
assert!(
|
||||
actual_expiration <= should_expired_at,
|
||||
"Magic link expiration exceeds expected maximum expiration time"
|
||||
);
|
||||
}
|
||||
502
tests/requests/auth.rs
Normal file
502
tests/requests/auth.rs
Normal file
@@ -0,0 +1,502 @@
|
||||
use insta::{assert_debug_snapshot, with_settings};
|
||||
use loco_rs::testing::prelude::*;
|
||||
use rstest::rstest;
|
||||
use serial_test::serial;
|
||||
use universal_web::{app::App, models::users};
|
||||
|
||||
use super::prepare_data;
|
||||
|
||||
// TODO: see how to dedup / extract this to app-local test utils
|
||||
// not to framework, because that would require a runtime dep on insta
|
||||
macro_rules! configure_insta {
|
||||
($($expr:expr),*) => {
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.set_prepend_module_to_snapshot(false);
|
||||
settings.set_snapshot_suffix("auth_request");
|
||||
let _guard = settings.bind_to_scope();
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_register() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": "12341234"
|
||||
});
|
||||
|
||||
let response = request.post("/api/auth/register").json(&payload).await;
|
||||
assert_eq!(
|
||||
response.status_code(),
|
||||
200,
|
||||
"Register request should succeed"
|
||||
);
|
||||
let saved_user = users::Model::find_by_email(&ctx.db, email).await;
|
||||
|
||||
with_settings!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(saved_user);
|
||||
});
|
||||
|
||||
let deliveries = ctx.mailer.unwrap().deliveries();
|
||||
assert_eq!(deliveries.count, 1, "Exactly one email should be sent");
|
||||
|
||||
// with_settings!({
|
||||
// filters => cleanup_email()
|
||||
// }, {
|
||||
// assert_debug_snapshot!(ctx.mailer.unwrap().deliveries());
|
||||
// });
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("login_with_valid_password", "12341234")]
|
||||
#[case("login_with_invalid_password", "invalid-password")]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let register_payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": "12341234"
|
||||
});
|
||||
|
||||
//Creating a new user
|
||||
let register_response = request
|
||||
.post("/api/auth/register")
|
||||
.json(®ister_payload)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
register_response.status_code(),
|
||||
200,
|
||||
"Register request should succeed"
|
||||
);
|
||||
|
||||
let user = users::Model::find_by_email(&ctx.db, email).await.unwrap();
|
||||
let email_verification_token = user
|
||||
.email_verification_token
|
||||
.expect("Email verification token should be generated");
|
||||
request
|
||||
.get(&format!("/api/auth/verify/{email_verification_token}"))
|
||||
.await;
|
||||
|
||||
//verify user request
|
||||
let response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": email,
|
||||
"password": password
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Make sure email_verified_at is set
|
||||
let user = users::Model::find_by_email(&ctx.db, email)
|
||||
.await
|
||||
.expect("Failed to find user by email");
|
||||
|
||||
assert!(
|
||||
user.email_verified_at.is_some(),
|
||||
"Expected the email to be verified, but it was not. User: {:?}",
|
||||
user
|
||||
);
|
||||
|
||||
with_settings!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(test_name, (response.status_code(), response.text()));
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn login_with_un_existing_email() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
|
||||
let login_response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": "un_existing@loco.rs",
|
||||
"password": "1234"
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(login_response.status_code(), 401, "Login request should return 401");
|
||||
login_response.assert_json(&serde_json::json!({"error": "unauthorized", "description": "You do not have permission to access this resource"}));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_login_without_verify() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let password = "12341234";
|
||||
let register_payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": password
|
||||
});
|
||||
|
||||
//Creating a new user
|
||||
let register_response = request
|
||||
.post("/api/auth/register")
|
||||
.json(®ister_payload)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
register_response.status_code(),
|
||||
200,
|
||||
"Register request should succeed"
|
||||
);
|
||||
|
||||
//verify user request
|
||||
let login_response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": email,
|
||||
"password": password
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
login_response.status_code(),
|
||||
200,
|
||||
"Login request should succeed"
|
||||
);
|
||||
|
||||
with_settings!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(login_response.text());
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn invalid_verification_token() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
let response = request.get("/api/auth/verify/invalid-token").await;
|
||||
|
||||
assert_eq!(response.status_code(), 401, "Verify request should reject");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_reset_password() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let login_data = prepare_data::init_user_login(&request, &ctx).await;
|
||||
|
||||
let forgot_payload = serde_json::json!({
|
||||
"email": login_data.user.email,
|
||||
});
|
||||
let forget_response = request.post("/api/auth/forgot").json(&forgot_payload).await;
|
||||
assert_eq!(
|
||||
forget_response.status_code(),
|
||||
200,
|
||||
"Forget request should succeed"
|
||||
);
|
||||
|
||||
let user = users::Model::find_by_email(&ctx.db, &login_data.user.email)
|
||||
.await
|
||||
.expect("Failed to find user by email");
|
||||
|
||||
assert!(
|
||||
user.reset_token.is_some(),
|
||||
"Expected reset_token to be set, but it was None. User: {user:?}"
|
||||
);
|
||||
assert!(
|
||||
user.reset_sent_at.is_some(),
|
||||
"Expected reset_sent_at to be set, but it was None. User: {user:?}"
|
||||
);
|
||||
|
||||
let new_password = "new-password";
|
||||
let reset_payload = serde_json::json!({
|
||||
"token": user.reset_token,
|
||||
"password": new_password,
|
||||
});
|
||||
|
||||
let reset_response = request.post("/api/auth/reset").json(&reset_payload).await;
|
||||
assert_eq!(
|
||||
reset_response.status_code(),
|
||||
200,
|
||||
"Reset password request should succeed"
|
||||
);
|
||||
|
||||
let user = users::Model::find_by_email(&ctx.db, &user.email)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(user.reset_token.is_none());
|
||||
assert!(user.reset_sent_at.is_none());
|
||||
|
||||
assert_debug_snapshot!(reset_response.text());
|
||||
|
||||
let login_response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": user.email,
|
||||
"password": new_password
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
login_response.status_code(),
|
||||
200,
|
||||
"Login request should succeed"
|
||||
);
|
||||
|
||||
let deliveries = ctx.mailer.unwrap().deliveries();
|
||||
assert_eq!(deliveries.count, 2, "Exactly one email should be sent");
|
||||
// with_settings!({
|
||||
// filters => cleanup_email()
|
||||
// }, {
|
||||
// assert_debug_snapshot!(deliveries.messages);
|
||||
// });
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_get_current_user() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let user = prepare_data::init_user_login(&request, &ctx).await;
|
||||
|
||||
let (auth_key, auth_value) = prepare_data::auth_header(&user.token);
|
||||
let response = request
|
||||
.get("/api/auth/current")
|
||||
.add_header(auth_key, auth_value)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
response.status_code(),
|
||||
200,
|
||||
"Current request should succeed"
|
||||
);
|
||||
|
||||
with_settings!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!((response.status_code(), response.text()));
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_auth_with_magic_link() {
|
||||
configure_insta!();
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
seed::<App>(&ctx).await.unwrap();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"email": "user1@example.com",
|
||||
});
|
||||
let response = request.post("/api/auth/magic-link").json(&payload).await;
|
||||
assert_eq!(
|
||||
response.status_code(),
|
||||
200,
|
||||
"Magic link request should succeed"
|
||||
);
|
||||
|
||||
let deliveries = ctx.mailer.unwrap().deliveries();
|
||||
assert_eq!(deliveries.count, 1, "Exactly one email should be sent");
|
||||
|
||||
// let redact_token = format!("[a-zA-Z0-9]{{{}}}", users::MAGIC_LINK_LENGTH);
|
||||
// with_settings!({
|
||||
// filters => {
|
||||
// let mut combined_filters = cleanup_email().clone();
|
||||
// combined_filters.extend(vec![(r"(\\r\\n|=\\r\\n)", ""), (redact_token.as_str(), "[REDACT_TOKEN]") ]);
|
||||
// combined_filters
|
||||
// }
|
||||
// }, {
|
||||
// assert_debug_snapshot!(deliveries.messages);
|
||||
// });
|
||||
|
||||
let user = users::Model::find_by_email(&ctx.db, "user1@example.com")
|
||||
.await
|
||||
.expect("User should be found");
|
||||
|
||||
let magic_link_token = user
|
||||
.magic_link_token
|
||||
.expect("Magic link token should be generated");
|
||||
let magic_link_response = request
|
||||
.get(&format!("/api/auth/magic-link/{magic_link_token}"))
|
||||
.await;
|
||||
assert_eq!(
|
||||
magic_link_response.status_code(),
|
||||
200,
|
||||
"Magic link authentication should succeed"
|
||||
);
|
||||
|
||||
with_settings!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(magic_link_response.text());
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_reject_invalid_email() {
|
||||
configure_insta!();
|
||||
request::<App, _, _>(|request, _ctx| async move {
|
||||
let invalid_email = "user1@temp-mail.com";
|
||||
let payload = serde_json::json!({
|
||||
"email": invalid_email,
|
||||
});
|
||||
let response = request.post("/api/auth/magic-link").json(&payload).await;
|
||||
assert_eq!(
|
||||
response.status_code(),
|
||||
400,
|
||||
"Expected request with invalid email '{invalid_email}' to be blocked, but it was allowed."
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_reject_invalid_magic_link_token() {
|
||||
configure_insta!();
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
seed::<App>(&ctx).await.unwrap();
|
||||
|
||||
let magic_link_response = request.get("/api/auth/magic-link/invalid-token").await;
|
||||
assert_eq!(
|
||||
magic_link_response.status_code(),
|
||||
401,
|
||||
"Magic link authentication should be rejected"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_resend_verification_email() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": "12341234"
|
||||
});
|
||||
|
||||
let response = request.post("/api/auth/register").json(&payload).await;
|
||||
assert_eq!(
|
||||
response.status_code(),
|
||||
200,
|
||||
"Register request should succeed"
|
||||
);
|
||||
|
||||
let resend_payload = serde_json::json!({ "email": email });
|
||||
|
||||
let resend_response = request
|
||||
.post("/api/auth/resend-verification-mail")
|
||||
.json(&resend_payload)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resend_response.status_code(),
|
||||
200,
|
||||
"Resend verification email should succeed"
|
||||
);
|
||||
|
||||
let deliveries = ctx.mailer.unwrap().deliveries();
|
||||
|
||||
assert_eq!(
|
||||
deliveries.count, 2,
|
||||
"Two emails should have been sent: welcome and re-verification"
|
||||
);
|
||||
|
||||
let user = users::Model::find_by_email(&ctx.db, email)
|
||||
.await
|
||||
.expect("User should exist");
|
||||
|
||||
with_settings!({
|
||||
filters => cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!("resend_verification_user", user);
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn cannot_resend_email_if_already_verified() {
|
||||
configure_insta!();
|
||||
|
||||
request::<App, _, _>(|request, ctx| async move {
|
||||
let email = "verified@loco.com";
|
||||
let payload = serde_json::json!({
|
||||
"name": "verified",
|
||||
"email": email,
|
||||
"password": "12341234"
|
||||
});
|
||||
|
||||
request.post("/api/auth/register").json(&payload).await;
|
||||
|
||||
// Verify user
|
||||
let user = users::Model::find_by_email(&ctx.db, email).await.unwrap();
|
||||
if let Some(token) = user.email_verification_token.clone() {
|
||||
request.get(&format!("/api/auth/verify/{token}")).await;
|
||||
}
|
||||
|
||||
// Try resending verification email
|
||||
let resend_payload = serde_json::json!({ "email": email });
|
||||
|
||||
let resend_response = request
|
||||
.post("/api/auth/resend-verification-mail")
|
||||
.json(&resend_payload)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resend_response.status_code(),
|
||||
200,
|
||||
"Should return 200 even if already verified"
|
||||
);
|
||||
|
||||
let deliveries = ctx.mailer.unwrap().deliveries();
|
||||
assert_eq!(
|
||||
deliveries.count, 1,
|
||||
"Only the original welcome email should be sent"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
2
tests/requests/mod.rs
Normal file
2
tests/requests/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod auth;
|
||||
mod prepare_data;
|
||||
57
tests/requests/prepare_data.rs
Normal file
57
tests/requests/prepare_data.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use loco_rs::{app::AppContext, TestServer};
|
||||
use universal_web::{models::users, views::auth::LoginResponse};
|
||||
|
||||
const USER_EMAIL: &str = "test@loco.com";
|
||||
const USER_PASSWORD: &str = "1234";
|
||||
|
||||
pub struct LoggedInUser {
|
||||
pub user: users::Model,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn init_user_login(request: &TestServer, ctx: &AppContext) -> LoggedInUser {
|
||||
let register_payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": USER_EMAIL,
|
||||
"password": USER_PASSWORD
|
||||
});
|
||||
|
||||
//Creating a new user
|
||||
request
|
||||
.post("/api/auth/register")
|
||||
.json(®ister_payload)
|
||||
.await;
|
||||
let user = users::Model::find_by_email(&ctx.db, USER_EMAIL)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let verify_payload = serde_json::json!({
|
||||
"token": user.email_verification_token,
|
||||
});
|
||||
|
||||
request.post("/api/auth/verify").json(&verify_payload).await;
|
||||
|
||||
let response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": USER_EMAIL,
|
||||
"password": USER_PASSWORD
|
||||
}))
|
||||
.await;
|
||||
|
||||
let login_response: LoginResponse = serde_json::from_str(&response.text()).unwrap();
|
||||
|
||||
LoggedInUser {
|
||||
user: users::Model::find_by_email(&ctx.db, USER_EMAIL)
|
||||
.await
|
||||
.unwrap(),
|
||||
token: login_response.token,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_header(token: &str) -> (HeaderName, HeaderValue) {
|
||||
let auth_header_value = HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap();
|
||||
|
||||
(HeaderName::from_static("authorization"), auth_header_value)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: magic_link_response.text()
|
||||
---
|
||||
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false}"
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: "(response.status_code(), response.text())"
|
||||
---
|
||||
(
|
||||
200,
|
||||
"{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: login_response.text()
|
||||
---
|
||||
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}"
|
||||
27
tests/requests/snapshots/can_register@auth_request.snap
Normal file
27
tests/requests/snapshots/can_register@auth_request.snap
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: saved_user
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: DATE,
|
||||
updated_at: DATE,
|
||||
id: ID
|
||||
pid: PID,
|
||||
email: "test@loco.com",
|
||||
password: "PASSWORD",
|
||||
api_key: "lo-PID",
|
||||
name: "loco",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: Some(
|
||||
"PID",
|
||||
),
|
||||
email_verification_sent_at: Some(
|
||||
DATE,
|
||||
),
|
||||
email_verified_at: None,
|
||||
magic_link_token: None,
|
||||
magic_link_expiration: None,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: "(reset_response.status_code(), reset_response.text())"
|
||||
---
|
||||
"null"
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: "(response.status_code(), response.text())"
|
||||
---
|
||||
(
|
||||
401,
|
||||
"{\"error\":\"unauthorized\",\"description\":\"You do not have permission to access this resource\"}",
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
expression: "(response.status_code(), response.text())"
|
||||
---
|
||||
(
|
||||
200,
|
||||
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":true}",
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
source: tests/requests/auth.rs
|
||||
assertion_line: 414
|
||||
expression: user
|
||||
---
|
||||
Model {
|
||||
created_at: DATE,
|
||||
updated_at: DATE,
|
||||
id: ID
|
||||
pid: PID,
|
||||
email: "test@loco.com",
|
||||
password: "PASSWORD",
|
||||
api_key: "lo-PID",
|
||||
name: "loco",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: Some(
|
||||
"PID",
|
||||
),
|
||||
email_verification_sent_at: Some(
|
||||
DATE,
|
||||
),
|
||||
email_verified_at: None,
|
||||
magic_link_token: None,
|
||||
magic_link_expiration: None,
|
||||
}
|
||||
1
tests/tasks/mod.rs
Normal file
1
tests/tasks/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
tests/workers/mod.rs
Normal file
1
tests/workers/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user