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