diff --git a/Cargo.lock b/Cargo.lock index 27046fb..379ae55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -468,22 +469,24 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" +checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" dependencies = [ "axum", "axum-core", "bytes", "cookie", "futures-util", + "headers", "http 1.1.0", "http-body", "http-body-util", "mime", + "multer", "pin-project-lite", "serde", - "tower 0.4.13", + "tower 0.5.1", "tower-layer", "tower-service", "tracing", @@ -500,6 +503,21 @@ dependencies = [ "syn 2.0.72", ] +[[package]] +name = "axum-range" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c30398a7f716ebdd7f3c8a4f7a7a6df48a30e002007fd57b2a7a00fac864bd" +dependencies = [ + "axum", + "axum-extra", + "bytes", + "futures", + "http-body", + "pin-project", + "tokio", +] + [[package]] name = "axum-test" version = "16.2.0" @@ -1269,6 +1287,15 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + [[package]] name = "english-to-cron" version = "0.1.2" @@ -1632,6 +1659,8 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "axum-extra", + "axum-range", "chrono", "fluent-templates", "include_dir", @@ -1644,6 +1673,8 @@ dependencies = [ "serde_json", "serial_test", "tokio", + "tower 0.5.1", + "tower-http", "tracing", "tracing-subscriber", "unic-langid", @@ -1760,6 +1791,30 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http 1.1.0", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.1.0", +] + [[package]] name = "heck" version = "0.4.1" @@ -2479,6 +2534,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.1.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nom" version = "7.1.3" diff --git a/Cargo.toml b/Cargo.toml index 24aae1d..9c46899 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ migration = { path = "migration" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1.33.0", default-features = false } +tower = { version = "0.5.1", features = ["util"] } +tower-http = { version = "0.6.1", features = ["fs", "trace"] } async-trait = "0.1.74" tracing = "0.1.40" chrono = "0.4" @@ -31,7 +33,7 @@ sea-orm = { version = "1.1.0", features = [ "macros", ] } -axum = "0.7.5" +axum = { version = "0.7.5", features = ["multipart"] } include_dir = "0.7" uuid = { version = "1.6.0", features = ["v4"] } tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] } @@ -39,6 +41,8 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] } # view engine i18n fluent-templates = { version = "0.8.0", features = ["tera"] } unic-langid = "0.9.4" +axum-range = "0.4.0" +axum-extra = { version = "0.9.4", features = ["multipart", "typed-header", "cookie"] } # /view engine [[bin]] diff --git a/assets/views/website/login.html b/assets/views/website/login.html new file mode 100644 index 0000000..bfc350d --- /dev/null +++ b/assets/views/website/login.html @@ -0,0 +1,16 @@ +{% extends "website/base.html" %} {% block content %} +
+
+ + + + + +
+ +{% endblock content%} diff --git a/config/development.yaml b/config/development.yaml index 33ef9a3..e50f678 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -136,3 +136,7 @@ auth: secret: PqRwLF2rhHe8J22oBeHy # Token expiration time in seconds expiration: 604800 # 7 days + location: + from: Cookie + name: token + diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 08e7ff4..924e93e 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -8,6 +8,7 @@ mod m20241029_235230_skills; mod m20241030_002154_jobs; mod m20241030_024340_projects; mod m20241030_024830_data; +mod m20241102_204505_add_file_name_to_data; pub struct Migrator; #[async_trait::async_trait] @@ -15,6 +16,7 @@ impl MigratorTrait for Migrator { fn migrations() -> Vec> { vec![ // inject-below + Box::new(m20241102_204505_add_file_name_to_data::Migration), Box::new(m20241030_024830_data::Migration), Box::new(m20241030_024340_projects::Migration), Box::new(m20241030_002154_jobs::Migration), diff --git a/migration/src/m20241102_204505_add_file_name_to_data.rs b/migration/src/m20241102_204505_add_file_name_to_data.rs new file mode 100644 index 0000000..2c6dd64 --- /dev/null +++ b/migration/src/m20241102_204505_add_file_name_to_data.rs @@ -0,0 +1,35 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(DeriveIden)] +enum Data { + Table, + FileName, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Data::Table) + .add_column_if_not_exists(string(Data::FileName)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Data::Table) + .drop_column(Data::FileName) + .to_owned(), + ) + .await + } +} diff --git a/src/app.rs b/src/app.rs index 474424e..8e1017b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,7 @@ use loco_rs::{ controller::AppRoutes, db::{self, truncate_table}, environment::Environment, + storage::{self, Storage}, task::Tasks, Result, }; @@ -47,6 +48,7 @@ impl Hooks for App { fn routes(_ctx: &AppContext) -> AppRoutes { AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::data::routes()) .add_route(controllers::auth::routes()) .add_route(controllers::website::routes()) } @@ -56,8 +58,18 @@ impl Hooks for App { Ok(()) } + async fn after_context(ctx: AppContext) -> Result { + let store = storage::drivers::local::new_with_prefix("uploads").map_err(Box::from)?; + + Ok(AppContext { + storage: Storage::single(store).into(), + ..ctx + }) + } + fn register_tasks(tasks: &mut Tasks) { tasks.register(tasks::seed::SeedData); + tasks.register(tasks::create_user::CreateUserData); } async fn truncate(db: &DatabaseConnection) -> Result<()> { diff --git a/src/controllers/auth.rs b/src/controllers/auth.rs index 27e3de7..0265e8e 100644 --- a/src/controllers/auth.rs +++ b/src/controllers/auth.rs @@ -1,14 +1,16 @@ use axum::debug_handler; +use axum::http::header; +use axum::http::HeaderValue; +use axum::response::Redirect; +use cookie::Cookie; +use cookie::CookieJar; use loco_rs::prelude::*; use serde::{Deserialize, Serialize}; use crate::{ mailers::auth::AuthMailer, - models::{ - _entities::users, - users::{LoginParams, RegisterParams}, - }, - views::auth::{CurrentResponse, LoginResponse}, + models::{_entities::users, users::LoginParams}, + views::auth::CurrentResponse, }; #[derive(Debug, Deserialize, Serialize)] pub struct VerifyParams { @@ -26,37 +28,6 @@ pub struct ResetParams { 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, - Json(params): Json, -) -> Result { - 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] @@ -121,7 +92,11 @@ async fn reset(State(ctx): State, Json(params): Json) - /// Creates a user login and returns a token #[debug_handler] -async fn login(State(ctx): State, Json(params): Json) -> Result { +async fn login( + State(ctx): State, + jar: CookieJar, + Form(params): Form, +) -> Result { let user = users::Model::find_by_email(&ctx.db, ¶ms.email).await?; let valid = user.verify_password(¶ms.password); @@ -136,7 +111,32 @@ async fn login(State(ctx): State, Json(params): Json) - .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; - format::json(LoginResponse::new(&user, &token)) + let cookie = Cookie::build(("token", token.clone())) + .path("/") + .http_only(true) + .secure(true) + .same_site(cookie::SameSite::Lax); + let cookie = jar.add(cookie); + + let cookie = cookie.get("token").map(|c| c.value().to_owned()); + + let mut response = Redirect::to("/").into_response(); + match cookie { + Some(token) => { + let cookie = format!("token={}; Path=/; HttpOnly; Secure; SameSite=Lax", token); + let authorization_header = format!("Bearer {}", token); + response + .headers_mut() + .append(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap()); + response.headers_mut().append( + header::AUTHORIZATION, + HeaderValue::from_str(&authorization_header).unwrap(), + ); + } + None => {} + } + + Ok(response) } #[debug_handler] @@ -145,13 +145,24 @@ async fn current(auth: auth::JWT, State(ctx): State) -> Result impl IntoResponse { + let cookie = "token=''; Path=/; HttpOnly; Secure; SameSite=Lax"; + let mut response = Redirect::to("/").into_response(); + + response + .headers_mut() + .append(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap()); + + response +} + 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)) + .add("/logout", get(logout)) } diff --git a/src/controllers/data.rs b/src/controllers/data.rs new file mode 100644 index 0000000..9f14d46 --- /dev/null +++ b/src/controllers/data.rs @@ -0,0 +1,39 @@ +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use axum::extract::Multipart; +use loco_rs::prelude::*; +use tokio::fs::File; + +use axum_range::KnownSize; +use axum_range::Ranged; + +use axum_extra::headers::Range; +use axum_extra::TypedHeader; + +use crate::services; + +pub async fn get_data( + auth: auth::JWT, + range: Option>, + Path(file_name): Path, + State(ctx): State, +) -> Result>> { + services::data::serve_data_file(&auth, range, &file_name, &ctx).await +} + +pub async fn upload_data( + auth: auth::JWT, + State(ctx): State, + payload: Multipart, +) -> Result { + services::data::add(&auth, &ctx, payload).await?; + format::html("

File uploaded successfully

") +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/data/") + .add("/upload", post(upload_data)) + .add("/:file_name", get(get_data)) +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 41de790..a19094d 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,2 +1,4 @@ pub mod auth; pub mod website; + +pub mod data; \ No newline at end of file diff --git a/src/controllers/website.rs b/src/controllers/website.rs index ae16eb1..b7a016a 100644 --- a/src/controllers/website.rs +++ b/src/controllers/website.rs @@ -12,6 +12,12 @@ pub async fn render_index( views::website::index(v, &ctx).await } -pub fn routes() -> Routes { - Routes::new().add("/", get(render_index)) +pub async fn render_login(ViewEngine(v): ViewEngine) -> impl IntoResponse { + views::website::login(v).await +} + +pub fn routes() -> Routes { + Routes::new() + .add("/", get(render_index)) + .add("/login", get(render_login)) } diff --git a/src/models/_entities/data.rs b/src/models/_entities/data.rs index 9ae0816..b9f9f38 100644 --- a/src/models/_entities/data.rs +++ b/src/models/_entities/data.rs @@ -12,6 +12,7 @@ pub struct Model { pub id: i32, pub file_url: String, pub protected: bool, + pub file_name: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/services/data.rs b/src/services/data.rs new file mode 100644 index 0000000..31a5430 --- /dev/null +++ b/src/services/data.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; + +use crate::models::_entities::data::{self, ActiveModel, Entity, Model}; +use crate::models::users::users; +use axum::extract::Multipart; +use axum_extra::headers::Range; +use axum_extra::TypedHeader; +use loco_rs::prelude::*; +use tokio::fs::File; + +use axum_range::KnownSize; +use axum_range::Ranged; + +pub async fn get_data_by_file_name(file_name: &str, ctx: &AppContext) -> ModelResult { + let data = Entity::find() + .filter(data::Column::FileName.eq(file_name)) + .one(&ctx.db) + .await?; + + data.ok_or_else(|| ModelError::EntityNotFound) +} + +pub async fn serve_data_file( + auth: &auth::JWT, + range: Option>, + file_name: &str, + ctx: &AppContext, +) -> Result>> { + let data = get_data_by_file_name(&file_name, &ctx).await?; + if data.protected { + match users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await { + Ok(_) => {} + Err(_) => return unauthorized("Unauthorized"), + } + } + + let file = File::open(&data.file_url).await?; + let body = KnownSize::file(file).await?; + let range = range.map(|TypedHeader(range)| range); + Ok(Ranged::new(range, body)) +} + +pub async fn add(auth: &auth::JWT, ctx: &AppContext, mut payload: Multipart) -> ModelResult { + let _current_user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; + let mut protected = None; + let mut file_name = None; + + while let Some(field) = payload + .next_field() + .await + .map_err(|_| ModelError::Any("Failed to get next field".into()))? + { + let name = field + .name() + .ok_or_else(|| ModelError::Any("Failed to get field name".into()))?; + match name { + "protected" => { + let value = field + .text() + .await + .map_err(|_| ModelError::Any("Failed to get text".into()))? + .parse::() + .map_err(|_| ModelError::Any("Failed to parse bool".into()))?; + protected = Some(value); + } + "file" => { + file_name = match field.file_name() { + Some(file_name) => Some(String::from(file_name)), + None => return Err(ModelError::Any("Failed to get file name".into())), + }; + + if file_name.is_none() { + return Err(ModelError::Any("Failed to get file name".into())); + } + + let path = PathBuf::from("uploads").join(file_name.as_ref().unwrap()); + + let content = field + .bytes() + .await + .map_err(|_| ModelError::Any("Failed to get bytes".into()))?; + + match ctx.storage.as_ref().upload(path.as_path(), &content).await { + Ok(_) => {} + Err(_) => return Err(ModelError::Any("Failed to save file to storage".into())), + } + } + _ => {} + } + } + + let protected = + protected.ok_or_else(|| ModelError::Any("Protected field is required".into()))?; + let file_name = file_name.ok_or_else(|| ModelError::Any("File field is required".into()))?; + + let mut item = ActiveModel { + ..Default::default() + }; + + item.protected = Set(protected); + item.file_name = Set(file_name); + + let item = item.insert(&ctx.db).await?; + Ok(item) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index bff97bc..69ebaae 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,2 +1,3 @@ +pub mod data; pub mod jobs; pub mod skills; diff --git a/src/tasks/create_user.rs b/src/tasks/create_user.rs new file mode 100644 index 0000000..259dff3 --- /dev/null +++ b/src/tasks/create_user.rs @@ -0,0 +1,39 @@ +use loco_rs::prelude::*; + +use crate::models::users::{self}; + +pub struct CreateUserData; + +#[async_trait] +impl Task for CreateUserData { + fn task(&self) -> TaskInfo { + TaskInfo { + name: "create_user".to_string(), + detail: "Task for creating a new user".to_string(), + } + } + + async fn run(&self, app_context: &AppContext, vars: &task::Vars) -> Result<()> { + let username = vars.cli_arg("username")?; + let email = vars.cli_arg("email")?; + let password = vars.cli_arg("password")?; + + let user = users::Model::create_with_password( + &app_context.db, + &users::RegisterParams { + name: username.to_string(), + email: email.to_string(), + password: password.to_string(), + }, + ) + .await?; + + tracing::info!( + user_id = user.id, + user_email = &user.email, + "User created successfully", + ); + + Ok(()) + } +} diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index 01fbdda..6baf1ac 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -1 +1,2 @@ +pub mod create_user; pub mod seed; diff --git a/src/views/website.rs b/src/views/website.rs index 54e19c4..d9c5948 100644 --- a/src/views/website.rs +++ b/src/views/website.rs @@ -12,3 +12,7 @@ pub async fn index(v: impl ViewRenderer, ctx: &AppContext) -> Result Result { + format::render().view(&v, "website/login.html", data!({})) +} diff --git a/tests/requests/data.rs b/tests/requests/data.rs new file mode 100644 index 0000000..6954dea --- /dev/null +++ b/tests/requests/data.rs @@ -0,0 +1,17 @@ +use gabrielkaszewski_rs::app::App; +use loco_rs::testing; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn can_get_data() { + testing::request::(|request, _ctx| async move { + let res = request.get("/data/").await; + assert_eq!(res.status_code(), 200); + + // you can assert content like this: + // assert_eq!(res.text(), "content"); + }) + .await; +} + diff --git a/tests/requests/mod.rs b/tests/requests/mod.rs index 887b7ce..5abdf06 100644 --- a/tests/requests/mod.rs +++ b/tests/requests/mod.rs @@ -1,2 +1,4 @@ mod auth; mod prepare_data; + +pub mod data; \ No newline at end of file