init commit
This commit is contained in:
72
src/app.rs
Normal file
72
src/app.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::path::Path;
|
||||
|
||||
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 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())
|
||||
.add_route(controllers::website::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);
|
||||
}
|
||||
|
||||
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
8
src/bin/main.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use loco_rs::cli;
|
||||
use gabrielkaszewski_rs::app::App;
|
||||
use migration::Migrator;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
cli::main::<App, Migrator>().await
|
||||
}
|
8
src/bin/tool.rs
Normal file
8
src/bin/tool.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use loco_rs::cli;
|
||||
use gabrielkaszewski_rs::app::App;
|
||||
use migration::Migrator;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
cli::main::<App, Migrator>().await
|
||||
}
|
157
src/controllers/auth.rs
Normal file
157
src/controllers/auth.rs
Normal 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, ¶ms).await;
|
||||
|
||||
let user = match res {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
tracing::info!(
|
||||
message = err.to_string(),
|
||||
user_email = ¶ms.email,
|
||||
"could not register user",
|
||||
);
|
||||
return format::json(());
|
||||
}
|
||||
};
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_email_verification_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// Verify register user. if the user not verified his email, he can't login to
|
||||
/// the system.
|
||||
#[debug_handler]
|
||||
async fn verify(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<VerifyParams>,
|
||||
) -> Result<Response> {
|
||||
let user = users::Model::find_by_verification_token(&ctx.db, ¶ms.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, ¶ms.email).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
return format::json(());
|
||||
};
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_forgot_password_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::forgot_password(&ctx, &user).await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// reset user password by the given parameters
|
||||
#[debug_handler]
|
||||
async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
tracing::info!("reset token not found");
|
||||
|
||||
return format::json(());
|
||||
};
|
||||
user.into_active_model()
|
||||
.reset_password(&ctx.db, ¶ms.password)
|
||||
.await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// Creates a user login and returns a token
|
||||
#[debug_handler]
|
||||
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
|
||||
let user = users::Model::find_by_email(&ctx.db, ¶ms.email).await?;
|
||||
|
||||
let valid = user.verify_password(¶ms.password);
|
||||
|
||||
if !valid {
|
||||
return unauthorized("unauthorized!");
|
||||
}
|
||||
|
||||
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||
|
||||
let token = user
|
||||
.generate_jwt(&jwt_secret.secret, &jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::json(LoginResponse::new(&user, &token))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
format::json(CurrentResponse::new(&user))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
2
src/controllers/mod.rs
Normal file
2
src/controllers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod website;
|
17
src/controllers/website.rs
Normal file
17
src/controllers/website.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::views;
|
||||
|
||||
pub async fn render_index(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
views::website::index(v, &ctx).await
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new().add("/", get(render_index))
|
||||
}
|
17
src/fixtures/users.yaml
Normal file
17
src/fixtures/users.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
- id: 1
|
||||
pid: 11111111-1111-1111-1111-111111111111
|
||||
email: user1@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
||||
name: user1
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
||||
- id: 2
|
||||
pid: 22222222-2222-2222-2222-222222222222
|
||||
email: user2@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
||||
name: user2
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
2
src/initializers/mod.rs
Normal file
2
src/initializers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#![allow(clippy::module_name_repetitions)]
|
||||
pub mod view_engine;
|
36
src/initializers/view_engine.rs
Normal file
36
src/initializers/view_engine.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
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";
|
||||
|
||||
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 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()))?;
|
||||
tera_engine
|
||||
.tera
|
||||
.register_function("t", FluentLoader::new(arc));
|
||||
info!("locales loaded");
|
||||
}
|
||||
|
||||
Ok(router.layer(Extension(ViewEngine::from(tera_engine))))
|
||||
}
|
||||
}
|
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod app;
|
||||
pub mod controllers;
|
||||
pub mod initializers;
|
||||
pub mod mailers;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
pub mod tasks;
|
||||
pub mod views;
|
||||
pub mod workers;
|
65
src/mailers/auth.rs
Normal file
65
src/mailers/auth.rs
Normal 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(())
|
||||
}
|
||||
}
|
11
src/mailers/auth/forgot/html.t
Normal file
11
src/mailers/auth/forgot/html.t
Normal file
@@ -0,0 +1,11 @@
|
||||
;<html>
|
||||
|
||||
<body>
|
||||
Hey {{name}},
|
||||
Forgot your password? No worries! You can reset it by clicking the link below:
|
||||
<a href="http://{{domain}}/reset#{{resetToken}}">Reset Your Password</a>
|
||||
If you didn't request a password reset, please ignore this email.
|
||||
Best regards,<br>The Loco Team</br>
|
||||
</body>
|
||||
|
||||
</html>
|
1
src/mailers/auth/forgot/subject.t
Normal file
1
src/mailers/auth/forgot/subject.t
Normal file
@@ -0,0 +1 @@
|
||||
Your reset password link
|
3
src/mailers/auth/forgot/text.t
Normal file
3
src/mailers/auth/forgot/text.t
Normal file
@@ -0,0 +1,3 @@
|
||||
Reset your password with this link:
|
||||
|
||||
http://localhost/reset#{{resetToken}}
|
13
src/mailers/auth/welcome/html.t
Normal file
13
src/mailers/auth/welcome/html.t
Normal file
@@ -0,0 +1,13 @@
|
||||
;<html>
|
||||
|
||||
<body>
|
||||
Dear {{name}},
|
||||
Welcome to Loco! You can now log in to your account.
|
||||
Before you get started, please verify your account by clicking the link below:
|
||||
<a href="http://{{domain}}/verify#{{verifyToken}}">
|
||||
Verify Your Account
|
||||
</a>
|
||||
<p>Best regards,<br>The Loco Team</p>
|
||||
</body>
|
||||
|
||||
</html>
|
1
src/mailers/auth/welcome/subject.t
Normal file
1
src/mailers/auth/welcome/subject.t
Normal file
@@ -0,0 +1 @@
|
||||
Welcome {{name}}
|
4
src/mailers/auth/welcome/text.t
Normal file
4
src/mailers/auth/welcome/text.t
Normal file
@@ -0,0 +1,4 @@
|
||||
Welcome {{name}}, you can now log in.
|
||||
Verify your account with the link below:
|
||||
|
||||
http://localhost/verify#{{verifyToken}}
|
1
src/mailers/mod.rs
Normal file
1
src/mailers/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
22
src/models/_entities/jobs.rs
Normal file
22
src/models/_entities/jobs.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "jobs")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub position: String,
|
||||
pub company: String,
|
||||
pub start_date: Date,
|
||||
pub end_date: Option<Date>,
|
||||
pub technologies: String,
|
||||
pub still_working: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
7
src/models/_entities/mod.rs
Normal file
7
src/models/_entities/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod jobs;
|
||||
pub mod skills;
|
||||
pub mod users;
|
5
src/models/_entities/prelude.rs
Normal file
5
src/models/_entities/prelude.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
||||
|
||||
pub use super::jobs::Entity as Jobs;
|
||||
pub use super::skills::Entity as Skills;
|
||||
pub use super::users::Entity as Users;
|
17
src/models/_entities/skills.rs
Normal file
17
src/models/_entities/skills.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "skills")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
28
src/models/_entities/users.rs
Normal file
28
src/models/_entities/users.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.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 {}
|
27
src/models/jobs.rs
Normal file
27
src/models/jobs.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use super::_entities::jobs::{ActiveModel, Entity};
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub type Jobs = Entity;
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
// extend activemodel below (keep comment for generators)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct JobWithTechnologies {
|
||||
pub id: i32,
|
||||
pub position: String,
|
||||
pub company: String,
|
||||
pub start_date: Date,
|
||||
pub end_date: Option<Date>,
|
||||
pub technologies: Vec<String>,
|
||||
pub still_working: bool,
|
||||
}
|
||||
|
||||
pub fn get_technologies_from_string(technologies: &str) -> Vec<String> {
|
||||
technologies
|
||||
.split(',')
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect()
|
||||
}
|
4
src/models/mod.rs
Normal file
4
src/models/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod _entities;
|
||||
pub mod users;
|
||||
pub mod skills;
|
||||
pub mod jobs;
|
7
src/models/skills.rs
Normal file
7
src/models/skills.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use super::_entities::skills::{ActiveModel, Entity};
|
||||
pub type Skills = Entity;
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
// extend activemodel below (keep comment for generators)
|
||||
}
|
298
src/models/users.rs
Normal file
298
src/models/users.rs
Normal 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, ¶ms.email)
|
||||
.build(),
|
||||
)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ModelError::EntityAlreadyExists {});
|
||||
}
|
||||
|
||||
let password_hash =
|
||||
hash::hash_password(¶ms.password).map_err(|e| ModelError::Any(e.into()))?;
|
||||
let user = users::ActiveModel {
|
||||
email: ActiveValue::set(params.email.to_string()),
|
||||
password: ActiveValue::set(password_hash),
|
||||
name: ActiveValue::set(params.name.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Creates a JWT
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when could not convert user claims to jwt token
|
||||
pub fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult<String> {
|
||||
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?)
|
||||
}
|
||||
}
|
31
src/services/jobs.rs
Normal file
31
src/services/jobs.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::models::{
|
||||
_entities::jobs::{ActiveModel, Entity, Model},
|
||||
jobs::{get_technologies_from_string, JobWithTechnologies},
|
||||
};
|
||||
|
||||
pub async fn get_all_jobs(ctx: &AppContext) -> Result<Vec<Model>> {
|
||||
let jobs = Entity::find().all(&ctx.db).await?;
|
||||
Ok(jobs)
|
||||
}
|
||||
|
||||
pub async fn get_all_jobs_with_technologies(ctx: &AppContext) -> Result<Vec<JobWithTechnologies>> {
|
||||
let jobs = Entity::find().all(&ctx.db).await?;
|
||||
let jobs_with_technologies = jobs
|
||||
.into_iter()
|
||||
.map(|job| {
|
||||
let technologies = get_technologies_from_string(&job.technologies);
|
||||
JobWithTechnologies {
|
||||
id: job.id,
|
||||
position: job.position,
|
||||
company: job.company,
|
||||
start_date: job.start_date,
|
||||
end_date: job.end_date,
|
||||
technologies,
|
||||
still_working: job.still_working,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(jobs_with_technologies)
|
||||
}
|
2
src/services/mod.rs
Normal file
2
src/services/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod jobs;
|
||||
pub mod skills;
|
8
src/services/skills.rs
Normal file
8
src/services/skills.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::models::_entities::skills::{ActiveModel, Entity, Model};
|
||||
|
||||
pub async fn get_all_skills(ctx: &AppContext) -> Result<Vec<Model>> {
|
||||
let skills = Entity::find().all(&ctx.db).await?;
|
||||
Ok(skills)
|
||||
}
|
1
src/tasks/mod.rs
Normal file
1
src/tasks/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod seed;
|
45
src/tasks/seed.rs
Normal file
45
src/tasks/seed.rs
Normal 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
41
src/views/auth.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::users;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub pid: String,
|
||||
pub name: String,
|
||||
pub is_verified: bool,
|
||||
}
|
||||
|
||||
impl LoginResponse {
|
||||
#[must_use]
|
||||
pub fn new(user: &users::Model, token: &String) -> Self {
|
||||
Self {
|
||||
token: token.to_string(),
|
||||
pid: user.pid.to_string(),
|
||||
name: user.name.clone(),
|
||||
is_verified: user.email_verified_at.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CurrentResponse {
|
||||
pub pid: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl CurrentResponse {
|
||||
#[must_use]
|
||||
pub fn new(user: &users::Model) -> Self {
|
||||
Self {
|
||||
pid: user.pid.to_string(),
|
||||
name: user.name.clone(),
|
||||
email: user.email.clone(),
|
||||
}
|
||||
}
|
||||
}
|
2
src/views/mod.rs
Normal file
2
src/views/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod website;
|
14
src/views/website.rs
Normal file
14
src/views/website.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::services;
|
||||
|
||||
pub async fn index(v: impl ViewRenderer, ctx: &AppContext) -> Result<impl IntoResponse> {
|
||||
let skills = services::skills::get_all_skills(ctx).await?;
|
||||
let jobs = services::jobs::get_all_jobs_with_technologies(ctx).await?;
|
||||
|
||||
format::render().view(
|
||||
&v,
|
||||
"website/index.html",
|
||||
data!({ "skills": skills, "jobs": jobs }),
|
||||
)
|
||||
}
|
40
src/workers/downloader.rs
Normal file
40
src/workers/downloader.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::models::users;
|
||||
|
||||
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...
|
||||
println!("================================================");
|
||||
println!("Sending payment report to user {}", args.user_guid);
|
||||
|
||||
sleep(Duration::from_millis(2000)).await;
|
||||
|
||||
let all = users::Entity::find()
|
||||
.all(&self.ctx.db)
|
||||
.await
|
||||
.map_err(Box::from)?;
|
||||
for user in &all {
|
||||
println!("user: {}", user.id);
|
||||
}
|
||||
println!("================================================");
|
||||
Ok(())
|
||||
}
|
||||
}
|
1
src/workers/mod.rs
Normal file
1
src/workers/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod downloader;
|
Reference in New Issue
Block a user