Initialize loco app
Some checks failed
CI / Check Style (push) Successful in 24s
CI / Run Clippy (push) Successful in 51s
CI / Run Tests (push) Failing after 1m35s

This commit is contained in:
Dylan Knutson
2024-11-30 12:41:45 -08:00
parent 9fe2bd20c7
commit ef95ac65f7
84 changed files with 8192 additions and 8 deletions

View File

@@ -1,2 +1,8 @@
[build] [build]
rustflags = ["-C", "link-arg=-fuse-ld=lld"] rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[alias]
loco = "run --"
loco-tool = "run --"
playground = "run --example playground"

View File

@@ -10,3 +10,7 @@ DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOSTNAM
PGADMIN_DEFAULT_EMAIL=admin@example.com PGADMIN_DEFAULT_EMAIL=admin@example.com
PGADMIN_DEFAULT_PASSWORD=password PGADMIN_DEFAULT_PASSWORD=password
PGADMIN_LISTEN_PORT=8080
# no password required
PGADMIN_CONFIG_SERVER_MODE=False
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=False

View File

@@ -8,16 +8,28 @@
"features": { "features": {
"ghcr.io/meaningful-ooo/devcontainer-features/fish:1": {}, "ghcr.io/meaningful-ooo/devcontainer-features/fish:1": {},
"ghcr.io/warrenbuckley/codespace-features/sqlite:1": {}, "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {},
"ghcr.io/nikobockerman/devcontainer-features/fish-persistent-data:2": {} "ghcr.io/nikobockerman/devcontainer-features/fish-persistent-data:2": {},
"ghcr.io/devcontainers-extra/features/act:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"azureDnsAutoDetection": true,
"installDockerBuildx": true,
"installDockerComposeSwitch": true,
"version": "latest",
"dockerDashComposeVersion": "latest"
}
}, },
// Features to add to the dev container. More info: https://containers.dev/features. // Features to add to the dev container. More info: https://containers.dev/features.
// "features": {}, // "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [ "forwardPorts": [
80 8080, // pgadmin
5150 // loco app
], ],
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "cargo install diesel_cli", "postCreateCommand": "cargo install loco && cargo install sea-orm-cli",
// Configure tool-specific properties. // Configure tool-specific properties.
"customizations": { "customizations": {
"vscode": { "vscode": {

View File

@@ -1,8 +1,8 @@
version: '3.8' version: '3.8'
volumes: volumes:
devcontainer-scraper-rs-postgres-data: devcontainer-thoth-postgres-data:
devcontainer-scraper-rs-cargo-cache: devcontainer-thoth-cargo-cache:
services: services:
app: app:
@@ -15,7 +15,7 @@ services:
volumes: volumes:
- ../..:/workspaces:cached - ../..:/workspaces:cached
- devcontainer-scraper-rs-cargo-cache:/usr/local/cargo - devcontainer-thoth-cargo-cache:/usr/local/cargo
# Overrides default command so things don't shut down after the process ends. # Overrides default command so things don't shut down after the process ends.
command: sleep infinity command: sleep infinity
@@ -30,7 +30,7 @@ services:
image: pgvector/pgvector:pg17 image: pgvector/pgvector:pg17
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- devcontainer-scraper-rs-postgres-data:/var/lib/postgresql/data - devcontainer-thoth-postgres-data:/var/lib/postgresql/data
env_file: env_file:
# Ensure that the variables in .env match the same variables in devcontainer.json # Ensure that the variables in .env match the same variables in devcontainer.json
- .env - .env

2
.env
View File

@@ -1 +1 @@
RUST_LOG=debug RUST_LOG=debug

96
.github/workflows/ci.yaml vendored Normal file
View File

@@ -0,0 +1,96 @@
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
- name: Install lld
run: sudo apt-get update && sudo apt-get install -qqy lld
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
components: clippy
- 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:
postgres:
image: postgres
env:
POSTGRES_DB: postgres_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5433:5432"
options: --health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout the code
uses: actions/checkout@v4
- name: Install lld
run: sudo apt-get update && sudo apt-get install -qqy lld
- 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:
DATABASE_URL: postgres://postgres:postgres@localhost:5433/postgres_test

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
**/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

5677
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

51
Cargo.toml Normal file
View File

@@ -0,0 +1,51 @@
[workspace]
[package]
name = "thoth"
version = "0.1.0"
edition = "2021"
publish = false
default-run = "thoth-cli"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[workspace.dependencies]
loco-rs = { version = "0.13.2" }
[dependencies]
loco-rs = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.33.0", default-features = false, features = [
"rt-multi-thread",
] }
async-trait = "0.1.74"
axum = "0.7.5"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] }
migration = { path = "migration" }
sea-orm = { version = "1.1.0", features = [
"sqlx-sqlite",
"sqlx-postgres",
"runtime-tokio-rustls",
"macros",
] }
chrono = "0.4"
validator = { version = "0.18" }
uuid = { version = "1.6.0", features = ["v4"] }
include_dir = "0.7"
# view engine i18n
fluent-templates = { version = "0.8.0", features = ["tera"] }
unic-langid = "0.9.4"
# /view engine
[[bin]]
name = "thoth-cli"
path = "src/bin/main.rs"
required-features = []
[dev-dependencies]
loco-rs = { workspace = true, features = ["testing"] }
serial_test = "3.1.1"
rstest = "0.21.0"
insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] }

58
README.md Normal file
View 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/).

View File

@@ -0,0 +1,4 @@
hello-world = Hallo Welt!
greeting = Hallochen { $name }!
.placeholder = Hallo Freund!
about = Uber

View 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
View File

@@ -0,0 +1 @@
-something = foo

3
assets/static/404.html Normal file
View File

@@ -0,0 +1,3 @@
<html><body>
not found :-(
</body></html>

BIN
assets/static/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View 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>

95
config/development.yaml Normal file
View File

@@ -0,0 +1,95 @@
# 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
# 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:
# 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/thoth_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: 30TMCBNXAVZ13jSkdH5t
# Token expiration time in seconds
expiration: 604800 # 7 days

95
config/test.yaml Normal file
View File

@@ -0,0 +1,95 @@
# 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: 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:
# 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:5433/thoth_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: false
# Authentication Configuration
auth:
# JWT authentication
jwt:
# Secret key for token generation and verification
secret: pt71Z6n9ZKMQImSVP8uh
# Token expiration time in seconds
expiration: 604800 # 7 days

21
examples/playground.rs Normal file
View File

@@ -0,0 +1,21 @@
#[allow(unused_imports)]
use loco_rs::{cli::playground, prelude::*};
use thoth::app::App;
#[tokio::main]
async fn main() -> loco_rs::Result<()> {
let _ctx = playground::<App>().await?;
// let active_model: articles::ActiveModel = 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(())
}

31
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist-ssr
dist/
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Common local dotenv files popularised by Create React App & Next.js
# https://rsbuild.dev/guide/advanced/env-vars#env-file
.env.local
.env.development.local
.env.production.local
.env.test.local

42
frontend/README.md Normal file
View File

@@ -0,0 +1,42 @@
# SaaS Frontend
## Batteries included
- [TypeScript](https://www.typescriptlang.org/): A typed superset of JavaScript
- [Rsbuild](https://rsbuild.dev/): A Rust-based web build tool
- [Biome](https://biomejs.dev/): A Rust-based formatter and sensible linter for the web
- [React](https://reactjs.org/): A JavaScript library for building user interfaces
If you don't like React for some reason, Rsbuild makes it easy to replace it with something else!
# Development
To get started with the development of the SaaS frontend, follow these steps:
### 1. Install Packages
Use the following command to install the required packages using pnpm:
```sh
pnpm install
```
### 2. Run in Development Mode
Once the packages are installed, run your frontend application in development mode with the following command:
```sh
pnpm dev
```
This will start the development frontend server serving via vit
### 3. Build The application
To build your application run the following command:
```sh
pnpm build
```
After the build `dist` folder is ready to served by loco. run loco `cargo loco start` and the frontend application will served via Loco

24
frontend/biome.json Normal file
View File

@@ -0,0 +1,24 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.2/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"enabled": true,
"indentStyle": "space"
}
},
"json": {
"formatter": {
"enabled": true,
"indentStyle": "space"
}
}
}

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "rsbuild dev --open",
"build": "rsbuild build",
"lint": "biome check src/",
"preview": "rsbuild preview"
},
"dependencies": {
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@biomejs/biome": "^1",
"@rsbuild/core": "^1",
"@rsbuild/plugin-react": "^1",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
// https://rsbuild.dev/guide/basic/configure-rsbuild
export default defineConfig({
plugins: [pluginReact()],
html: {
favicon: "src/assets/favicon.ico",
title: "Loco SaaS Starter",
},
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:5150",
changeOrigin: true,
secure: false,
},
},
},
});

105
frontend/src/LocoSplash.tsx Normal file
View File

@@ -0,0 +1,105 @@
export const LocoSplash = () => {
return (
<div>
<header className="navbar fixed-top">
<div className="container">
<a href="https://loco.rs?ref=starter">Loco</a>
<ul className="navbar-nav ">
<li className="">
<a
className=""
href="https://github.com/loco-rs/loco?ref=starter"
target="_blank"
rel="noreferrer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="feather feather-github"
>
<title>Loco GitHub repo</title>
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
</svg>
</a>
</li>
<li className="">
<a
className=""
href="https://github.com/loco-rs/loco/stargazers?ref=starter"
target="_blank"
rel="noreferrer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="feather feather-star"
>
<title>Loco GitHub stars</title>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</a>
</li>
</ul>
</div>
</header>
<div className="logo">
<h1>Loco: SaaS application</h1>
<img src="https://loco.rs/icon.svg" className="logo" alt="Loco logo" />
</div>
<footer>
<ul>
<li>
<a
href="https://loco.rs?ref=starter"
target="_blank"
rel="noreferrer"
>
Our Documentation
</a>
</li>
<li>
<a
href="https://github.com/loco-rs/loco?ref=starter"
target="_blank"
rel="noreferrer"
>
GitHub
</a>
</li>
<li>
<a
href="https://github.com/loco-rs/loco/issues?ref=starter"
target="_blank"
rel="noreferrer"
>
Found a bug?
</a>
</li>
<li>
<a
href="https://github.com/loco-rs/loco/discussions?ref=starter"
target="_blank"
rel="noreferrer"
>
Needs help?
</a>
</li>
</ul>
</footer>
</div>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@rsbuild/core/types" />

100
frontend/src/index.css Normal file
View File

@@ -0,0 +1,100 @@
body {
margin: 0;
font-family: "Arimo", -apple-system, blinkmacsystemfont, "Segoe UI", roboto, "Helvetica Neue", arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
background: #212529;
color: #dee2e6;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(29, 45, 53, 0)
}
ul {
margin-top: 0;
margin-bottom: 1rem;
list-style: none;
}
a {
color: #dee2e6;
text-decoration: none
}
.container {
max-width: 1320px;
padding-right: var(--bs-gutter-x, 24px);
padding-left: var(--bs-gutter-x, 24px);
margin-right: auto;
margin-left: auto
}
.navbar {
padding-top: .5rem;
padding-bottom: .5rem
}
.navbar .container {
display: flex;
justify-content: space-between
}
.navbar-nav {
margin-bottom: 0;
}
.navbar-nav li {
display: inline-flex;
margin-right: 10px;
}
.fixed-top {
position: fixed;
top: 0;
right: 0;
left: 0;
}
body {
font-size: 1rem;
padding-top: 6rem !important
}
.navbar {
border-bottom: 1px solid #2a2f34;
}
.logo {
max-width: 1280px;
margin: 0 auto;
text-align: center;
}
.logo img {
width: 250px;
}
footer {
position: absolute;
bottom: 0;
width: 100%;
text-align: center;
}
footer ul {
display: inline-block;
padding: 0;
}
footer ul li {
display: inline-flex;
align-items: center;
margin: 0 5px;
list-style: none;
}
footer ul li:not(:last-child) {
border-right: 1px solid #ccc;
padding-right: 5px;
height: 15px;
}

17
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { LocoSplash } from "./LocoSplash";
import "./index.css";
const root = document.getElementById("root");
if (!root) {
throw new Error("No root element found");
}
ReactDOM.createRoot(root).render(
<React.StrictMode>
<LocoSplash />
</React.StrictMode>,
);

15
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "ES2020"],
"module": "ESNext",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"moduleResolution": "bundler",
"useDefineForClassFields": true
},
"include": ["src"]
}

23
migration/Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
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
]

17
migration/src/lib.rs Normal file
View File

@@ -0,0 +1,17 @@
#![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)
]
}
}

View File

@@ -0,0 +1,50 @@
use loco_rs::schema::table_auto_tz;
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = table_auto_tz(Users::Table)
.col(pk_auto(Users::Id))
.col(uuid(Users::Pid))
.col(string_uniq(Users::Email))
.col(string(Users::Password))
.col(string(Users::ApiKey).unique_key())
.col(string(Users::Name))
.col(string_null(Users::ResetToken))
.col(timestamp_with_time_zone_null(Users::ResetSentAt))
.col(string_null(Users::EmailVerificationToken))
.col(timestamp_with_time_zone_null(
Users::EmailVerificationSentAt,
))
.col(timestamp_with_time_zone_null(Users::EmailVerifiedAt))
.to_owned();
manager.create_table(table).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Users::Table).to_owned())
.await
}
}
#[derive(Iden)]
pub enum Users {
Table,
Id,
Pid,
Email,
Name,
Password,
ApiKey,
ResetToken,
ResetSentAt,
EmailVerificationToken,
EmailVerificationSentAt,
EmailVerifiedAt,
}

68
src/app.rs Normal file
View File

@@ -0,0 +1,68 @@
use async_trait::async_trait;
use loco_rs::{
app::{AppContext, Hooks, Initializer},
bgworker::{BackgroundWorker, Queue},
boot::{create_app, BootResult, StartMode},
controller::AppRoutes,
db::{self, truncate_table},
environment::Environment,
task::Tasks,
Result,
};
use migration::Migrator;
use sea_orm::DatabaseConnection;
use std::path::Path;
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) -> Result<BootResult> {
create_app::<Self, Migrator>(mode, environment).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(())
}
fn register_tasks(tasks: &mut Tasks) {
tasks.register(tasks::seed::SeedData);
// tasks-inject (do not remove)
}
async fn truncate(db: &DatabaseConnection) -> Result<()> {
truncate_table(db, users::Entity).await?;
Ok(())
}
async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> {
db::seed::<users::ActiveModel>(db, &base.join("users.yaml").display().to_string()).await?;
Ok(())
}
}

8
src/bin/main.rs Normal file
View File

@@ -0,0 +1,8 @@
use loco_rs::cli;
use migration::Migrator;
use thoth::app::App;
#[tokio::main]
async fn main() -> loco_rs::Result<()> {
cli::main::<App, Migrator>().await
}

157
src/controllers/auth.rs Normal file
View File

@@ -0,0 +1,157 @@
use axum::debug_handler;
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{
mailers::auth::AuthMailer,
models::{
_entities::users,
users::{LoginParams, RegisterParams},
},
views::auth::{CurrentResponse, LoginResponse},
};
#[derive(Debug, Deserialize, Serialize)]
pub struct VerifyParams {
pub token: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ForgotParams {
pub email: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ResetParams {
pub token: String,
pub password: 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, &params).await;
let user = match res {
Ok(user) => user,
Err(err) => {
tracing::info!(
message = err.to_string(),
user_email = &params.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>,
Json(params): Json<VerifyParams>,
) -> Result<Response> {
let user = users::Model::find_by_verification_token(&ctx.db, &params.token).await?;
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, &params.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, &params.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, &params.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 user = users::Model::find_by_email(&ctx.db, &params.email).await?;
let valid = user.verify_password(&params.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))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/auth")
.add("/register", post(register))
.add("/verify", post(verify))
.add("/login", post(login))
.add("/forgot", post(forgot))
.add("/reset", post(reset))
.add("/current", get(current))
}

1
src/controllers/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod auth;

17
src/fixtures/users.yaml Normal file
View 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
View File

@@ -0,0 +1 @@
pub mod view_engine;

View File

@@ -0,0 +1,46 @@
use axum::{async_trait, 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> {
#[allow(unused_mut)]
let mut tera_engine = engines::TeraView::build()?;
if std::path::Path::new(I18N_DIR).exists() {
let arc = 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()))?;
#[cfg(debug_assertions)]
tera_engine
.tera
.lock()
.expect("lock")
.register_function("t", FluentLoader::new(arc));
#[cfg(not(debug_assertions))]
tera_engine
.tera
.register_function("t", FluentLoader::new(arc));
info!("locales loaded");
}
Ok(router.layer(Extension(ViewEngine::from(tera_engine))))
}
}

8
src/lib.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod app;
pub mod controllers;
pub mod initializers;
pub mod mailers;
pub mod models;
pub mod tasks;
pub mod views;
pub mod workers;

65
src/mailers/auth.rs Normal file
View File

@@ -0,0 +1,65 @@
// 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");
// #[derive(Mailer)] // -- disabled for faster build speed. it works. but lets
// move on for now.
#[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(())
}
}

View 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>

View File

@@ -0,0 +1 @@
Your reset password link

View File

@@ -0,0 +1,3 @@
Reset your password with this link:
http://localhost/reset#{{resetToken}}

View 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="http://{{domain}}/verify#{{verifyToken}}">
Verify Your Account
</a>
<p>Best regards,<br>The Loco Team</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Welcome {{name}}

View File

@@ -0,0 +1,4 @@
Welcome {{name}}, you can now log in.
Verify your account with the link below:
http://localhost/verify#{{verifyToken}}

1
src/mailers/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod auth;

View File

@@ -0,0 +1,5 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub mod prelude;
pub mod users;

View File

@@ -0,0 +1,3 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub use super::users::Entity as Users;

View File

@@ -0,0 +1,28 @@
//! `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>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

2
src/models/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod _entities;
pub mod users;

298
src/models/users.rs Normal file
View File

@@ -0,0 +1,298 @@
use async_trait::async_trait;
use chrono::offset::Local;
use loco_rs::{auth::jwt, hash, prelude::*};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub use super::_entities::users::{self, ActiveModel, Entity, Model};
#[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(custom(function = "validation::is_valid_email"))]
pub email: String,
}
impl Validatable for super::_entities::users::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 super::_entities::users::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 super::_entities::users::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 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, &params.email)
.build(),
)
.one(&txn)
.await?
.is_some()
{
return Err(ModelError::EntityAlreadyExists {});
}
let password_hash =
hash::hash_password(&params.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> {
Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), None)?)
}
}
impl super::_entities::users::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()));
Ok(self.update(db).await?)
}
/// 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()));
Ok(self.update(db).await?)
}
/// 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()));
Ok(self.update(db).await?)
}
/// 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);
Ok(self.update(db).await?)
}
}

1
src/tasks/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod seed;

45
src/tasks/seed.rs Normal file
View File

@@ -0,0 +1,45 @@
//! This task implements data seeding functionality for initializing new
//! development/demo environments.
//!
//! # Example
//!
//! Run the task with the following command:
//! ```sh
//! cargo run task
//! ```
//!
//! To override existing data and reset the data structure, use the following
//! command with the `refresh:true` argument:
//! ```sh
//! cargo run task seed_data refresh:true
//! ```
use loco_rs::{db, prelude::*};
use migration::Migrator;
use crate::app::App;
#[allow(clippy::module_name_repetitions)]
pub struct SeedData;
#[async_trait]
impl Task for SeedData {
fn task(&self) -> TaskInfo {
TaskInfo {
name: "seed_data".to_string(),
detail: "Task for seeding data".to_string(),
}
}
async fn run(&self, app_context: &AppContext, vars: &task::Vars) -> Result<()> {
let refresh = vars
.cli_arg("refresh")
.is_ok_and(|refresh| refresh == "true");
if refresh {
db::reset::<Migrator>(&app_context.db).await?;
}
let path = std::path::Path::new("src/fixtures");
db::run_app_seed::<App>(&app_context.db, path).await?;
Ok(())
}
}

41
src/views/auth.rs Normal file
View 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
View File

@@ -0,0 +1 @@
pub mod auth;

23
src/workers/downloader.rs Normal file
View 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
View File

@@ -0,0 +1 @@
pub mod downloader;

4
tests/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
mod models;
mod requests;
mod tasks;
mod workers;

1
tests/models/mod.rs Normal file
View File

@@ -0,0 +1 @@
mod users;

View File

@@ -0,0 +1,21 @@
---
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,
},
)

View File

@@ -0,0 +1,7 @@
---
source: tests/models/users.rs
expression: non_existing_user_results
---
Err(
EntityNotFound,
)

View File

@@ -0,0 +1,21 @@
---
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,
},
)

View File

@@ -0,0 +1,7 @@
---
source: tests/models/users.rs
expression: non_existing_user_results
---
Err(
EntityNotFound,
)

View File

@@ -0,0 +1,21 @@
---
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,
},
)

View File

@@ -0,0 +1,9 @@
---
source: tests/models/users.rs
expression: res
---
Err(
Custom(
"{\"email\":[{\"code\":\"invalid email\",\"message\":null}],\"name\":[{\"code\":\"length\",\"message\":\"Name must be at least 2 characters long.\"}]}",
),
)

View File

@@ -0,0 +1,7 @@
---
source: tests/models/users.rs
expression: new_user
---
Err(
EntityAlreadyExists,
)

223
tests/models/users.rs Normal file
View File

@@ -0,0 +1,223 @@
use insta::assert_debug_snapshot;
use loco_rs::{model::ModelError, testing};
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
use serial_test::serial;
use thoth::{
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 = testing::boot_test::<App>().await.unwrap();
let res = users::ActiveModel {
name: ActiveValue::set("1".to_string()),
email: ActiveValue::set("invalid-email".to_string()),
..Default::default()
}
.insert(&boot.app_context.db)
.await;
assert_debug_snapshot!(res);
}
#[tokio::test]
#[serial]
async fn can_create_with_password() {
configure_insta!();
let boot = testing::boot_test::<App>().await.unwrap();
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, &params).await;
insta::with_settings!({
filters => testing::cleanup_user_model()
}, {
assert_debug_snapshot!(res);
});
}
#[tokio::test]
#[serial]
async fn handle_create_with_password_with_duplicate() {
configure_insta!();
let boot = testing::boot_test::<App>().await.unwrap();
testing::seed::<App>(&boot.app_context.db).await.unwrap();
let new_user: Result<Model, ModelError> = 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 = testing::boot_test::<App>().await.unwrap();
testing::seed::<App>(&boot.app_context.db).await.unwrap();
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 = testing::boot_test::<App>().await.unwrap();
testing::seed::<App>(&boot.app_context.db).await.unwrap();
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 = testing::boot_test::<App>().await.unwrap();
testing::seed::<App>(&boot.app_context.db).await.unwrap();
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.unwrap();
assert!(user.email_verification_sent_at.is_none());
assert!(user.email_verification_token.is_none());
assert!(user
.into_active_model()
.set_email_verification_sent(&boot.app_context.db)
.await
.is_ok());
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.unwrap();
assert!(user.email_verification_sent_at.is_some());
assert!(user.email_verification_token.is_some());
}
#[tokio::test]
#[serial]
async fn can_set_forgot_password_sent() {
configure_insta!();
let boot = testing::boot_test::<App>().await.unwrap();
testing::seed::<App>(&boot.app_context.db).await.unwrap();
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.unwrap();
assert!(user.reset_sent_at.is_none());
assert!(user.reset_token.is_none());
assert!(user
.into_active_model()
.set_forgot_password_sent(&boot.app_context.db)
.await
.is_ok());
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.unwrap();
assert!(user.reset_sent_at.is_some());
assert!(user.reset_token.is_some());
}
#[tokio::test]
#[serial]
async fn can_verified() {
configure_insta!();
let boot = testing::boot_test::<App>().await.unwrap();
testing::seed::<App>(&boot.app_context.db).await.unwrap();
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.unwrap();
assert!(user.email_verified_at.is_none());
assert!(user
.into_active_model()
.verified(&boot.app_context.db)
.await
.is_ok());
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.unwrap();
assert!(user.email_verified_at.is_some());
}
#[tokio::test]
#[serial]
async fn can_reset_password() {
configure_insta!();
let boot = testing::boot_test::<App>().await.unwrap();
testing::seed::<App>(&boot.app_context.db).await.unwrap();
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.unwrap();
assert!(user.verify_password("12341234"));
assert!(user
.clone()
.into_active_model()
.reset_password(&boot.app_context.db, "new-password")
.await
.is_ok());
assert!(
Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
.await
.unwrap()
.verify_password("new-password")
);
}

218
tests/requests/auth.rs Normal file
View File

@@ -0,0 +1,218 @@
use insta::{assert_debug_snapshot, with_settings};
use loco_rs::testing;
use rstest::rstest;
use serial_test::serial;
use thoth::{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!();
testing::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;
let saved_user = users::Model::find_by_email(&ctx.db, email).await;
with_settings!({
filters => testing::cleanup_user_model()
}, {
assert_debug_snapshot!(saved_user);
});
with_settings!({
filters => testing::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!();
testing::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
_ = request
.post("/api/auth/register")
.json(&register_payload)
.await;
let user = users::Model::find_by_email(&ctx.db, email).await.unwrap();
let verify_payload = serde_json::json!({
"token": user.email_verification_token,
});
request.post("/api/auth/verify").json(&verify_payload).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
assert!(users::Model::find_by_email(&ctx.db, email)
.await
.unwrap()
.email_verified_at
.is_some());
with_settings!({
filters => testing::cleanup_user_model()
}, {
assert_debug_snapshot!(test_name, (response.status_code(), response.text()));
});
})
.await;
}
#[tokio::test]
#[serial]
async fn can_login_without_verify() {
configure_insta!();
testing::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
_ = request
.post("/api/auth/register")
.json(&register_payload)
.await;
//verify user request
let response = request
.post("/api/auth/login")
.json(&serde_json::json!({
"email": email,
"password": password
}))
.await;
with_settings!({
filters => testing::cleanup_user_model()
}, {
assert_debug_snapshot!((response.status_code(), response.text()));
});
})
.await;
}
#[tokio::test]
#[serial]
async fn can_reset_password() {
configure_insta!();
testing::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,
});
_ = request.post("/api/auth/forgot").json(&forgot_payload).await;
let user = users::Model::find_by_email(&ctx.db, &login_data.user.email)
.await
.unwrap();
assert!(user.reset_token.is_some());
assert!(user.reset_sent_at.is_some());
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;
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.status_code(), reset_response.text()));
let response = request
.post("/api/auth/login")
.json(&serde_json::json!({
"email": user.email,
"password": new_password
}))
.await;
assert_eq!(response.status_code(), 200);
with_settings!({
filters => testing::cleanup_email()
}, {
assert_debug_snapshot!(ctx.mailer.unwrap().deliveries());
});
})
.await;
}
#[tokio::test]
#[serial]
async fn can_get_current_user() {
configure_insta!();
testing::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;
with_settings!({
filters => testing::cleanup_user_model()
}, {
assert_debug_snapshot!((response.status_code(), response.text()));
});
})
.await;
}

2
tests/requests/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
mod auth;
mod prepare_data;

View File

@@ -0,0 +1,57 @@
use axum::http::{HeaderName, HeaderValue};
use loco_rs::{app::AppContext, TestServer};
use thoth::{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(&register_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)
}

View File

@@ -0,0 +1,8 @@
---
source: tests/requests/auth.rs
expression: "(response.status_code(), response.text())"
---
(
200,
"{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}",
)

View File

@@ -0,0 +1,8 @@
---
source: tests/requests/auth.rs
expression: "(response.status_code(), response.text())"
---
(
200,
"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}",
)

View File

@@ -0,0 +1,8 @@
---
source: tests/requests/auth.rs
expression: ctx.mailer.unwrap().deliveries()
---
Deliveries {
count: 0,
messages: [],
}

View File

@@ -0,0 +1,25 @@
---
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,
},
)

View File

@@ -0,0 +1,8 @@
---
source: tests/requests/auth.rs
expression: ctx.mailer.unwrap().deliveries()
---
Deliveries {
count: 0,
messages: [],
}

View File

@@ -0,0 +1,8 @@
---
source: tests/requests/auth.rs
expression: "(reset_response.status_code(), reset_response.text())"
---
(
200,
"null",
)

View File

@@ -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\"}",
)

View File

@@ -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}",
)

1
tests/tasks/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod seed;

17
tests/tasks/seed.rs Normal file
View File

@@ -0,0 +1,17 @@
use loco_rs::{boot::run_task, task, testing};
use serial_test::serial;
use thoth::app::App;
#[tokio::test]
#[serial]
async fn test_can_seed_data() {
let boot = testing::boot_test::<App>().await.unwrap();
assert!(run_task::<App>(
&boot.app_context,
Some(&"seed_data".to_string()),
&task::Vars::default()
)
.await
.is_ok());
}

1
tests/workers/mod.rs Normal file
View File

@@ -0,0 +1 @@