loco rewrite
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-05-17 13:12:08 +02:00
commit 9fff6fbf7f
72 changed files with 3126 additions and 0 deletions

74
src/app.rs Normal file
View 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
View 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
View 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, &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>, 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, &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 Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
tracing::debug!(
email = params.email,
"login attempt with non-existent email"
);
return unauthorized("Invalid credentials!");
};
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))
}
/// 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(&params.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, &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
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, &params.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
View File

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

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

@@ -0,0 +1 @@

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,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
View 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
View 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(())
}
}

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,8 @@
;<html>
<body>
<p>Magic link example:</p>
<a href="{{host}}/api/auth/magic-link/{{token}}">
Verify Your Account
</a>
</body>
</html>

View File

@@ -0,0 +1 @@
Magic link example

View File

@@ -0,0 +1,2 @@
Magic link with this link:
{{host}}/api/auth/magic-link/{{token}}

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="{{domain}}/api/auth/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:
{{domain}}/api/auth/verify/{{verifyToken}}

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

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

View File

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

View File

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

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

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

369
src/models/users.rs Normal file
View 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, &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> {
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
View File

@@ -0,0 +1 @@

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;