Compare commits
2 Commits
880468282a
...
e7eaa42064
| Author | SHA1 | Date | |
|---|---|---|---|
| e7eaa42064 | |||
| a54ec4ed34 |
40
backend/Cargo.lock
generated
40
backend/Cargo.lock
generated
@@ -32,6 +32,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atoi"
|
name = "atoi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -173,12 +185,14 @@ name = "backend"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-test",
|
"axum-test",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"lazy-regex",
|
"lazy-regex",
|
||||||
"opendal",
|
"opendal",
|
||||||
|
"rand 0.10.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
@@ -222,6 +236,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -1735,6 +1758,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -1967,9 +2001,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.10.0"
|
version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
|
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chacha20",
|
"chacha20",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
@@ -2181,7 +2215,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"mime",
|
"mime",
|
||||||
"rand 0.10.0",
|
"rand 0.10.1",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"
|
|||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
opendal = { version = "0.56.0", features = ["tests", "services-fs"] }
|
opendal = { version = "0.56.0", features = ["tests", "services-fs"] }
|
||||||
|
argon2 = "0.5.3"
|
||||||
|
rand = "0.10.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
axum-test = "20.0.0"
|
axum-test = "20.0.0"
|
||||||
|
|||||||
6
backend/migrations/20260523200650_create_users.sql
Normal file
6
backend/migrations/20260523200650_create_users.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
6
backend/migrations/20260523200652_create_sessions.sql
Normal file
6
backend/migrations/20260523200652_create_sessions.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Ctx {
|
pub struct Ctx {
|
||||||
user_id: u64,
|
user_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ctx {
|
impl Ctx {
|
||||||
pub fn new(user_id: u64) -> Self {
|
pub fn new(user_id: i64) -> Self {
|
||||||
Self { user_id }
|
Self { user_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_id(&self) -> u64 {
|
pub fn user_id(&self) -> i64 {
|
||||||
self.user_id
|
self.user_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use tracing::info;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum LoftError {
|
pub enum LoftError {
|
||||||
LoginFail,
|
LoginFail,
|
||||||
|
RegisterFail,
|
||||||
AuthFailNoAuthTokenCookie,
|
AuthFailNoAuthTokenCookie,
|
||||||
AuthFailTokenWrongFormat,
|
|
||||||
AuthFailCtxNotInRequestExt,
|
AuthFailCtxNotInRequestExt,
|
||||||
FileIdNotFound,
|
FileIdNotFound,
|
||||||
UndefinedErrorType,
|
UndefinedErrorType,
|
||||||
@@ -25,8 +25,8 @@ impl IntoResponse for LoftError {
|
|||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> axum::response::Response {
|
||||||
match self {
|
match self {
|
||||||
Self::LoginFail
|
Self::LoginFail
|
||||||
|
| Self::RegisterFail
|
||||||
| Self::AuthFailNoAuthTokenCookie
|
| Self::AuthFailNoAuthTokenCookie
|
||||||
| Self::AuthFailTokenWrongFormat
|
|
||||||
| Self::AuthFailCtxNotInRequestExt => {
|
| Self::AuthFailCtxNotInRequestExt => {
|
||||||
info!("UNAUTHORIZED");
|
info!("UNAUTHORIZED");
|
||||||
StatusCode::UNAUTHORIZED.into_response()
|
StatusCode::UNAUTHORIZED.into_response()
|
||||||
|
|||||||
@@ -11,18 +11,19 @@ use axum::{
|
|||||||
middleware,
|
middleware,
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
use tower_cookies::CookieManagerLayer;
|
use tower_cookies::CookieManagerLayer;
|
||||||
use tower_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer};
|
use tower_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::FileRepository,
|
model::{FileRepository, UserRepository},
|
||||||
web::{
|
web::{
|
||||||
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
||||||
routes_file::routes_file,
|
routes_file::routes_file,
|
||||||
routes_health::routes_health,
|
routes_health::routes_health,
|
||||||
routes_login::routes_login,
|
routes_login::routes_auth,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,20 +38,25 @@ async fn main() -> Result<()> {
|
|||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let file_repository = FileRepository::new().await?;
|
dotenvy::dotenv().ok();
|
||||||
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
|
let file_repository = FileRepository::new(pool.clone())?;
|
||||||
let routes_file = routes_file(file_repository.clone())
|
let routes_file = routes_file(file_repository.clone())
|
||||||
.route_layer(middleware::from_fn(mw_require_auth))
|
.route_layer(middleware::from_fn(mw_require_auth))
|
||||||
.layer(DefaultBodyLimit::disable());
|
.layer(DefaultBodyLimit::disable());
|
||||||
|
|
||||||
|
let user_repository = UserRepository::new(pool)?;
|
||||||
|
let routes_auth = routes_auth(user_repository.clone());
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", routes_file)
|
.nest("/api", routes_file)
|
||||||
|
.nest("/api/auth", routes_auth)
|
||||||
.merge(routes_health())
|
.merge(routes_health())
|
||||||
.merge(routes_login())
|
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(middleware::map_response(main_response_mapper))
|
.layer(middleware::map_response(main_response_mapper))
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
file_repository,
|
user_repository,
|
||||||
mw_ctx_resolver,
|
mw_ctx_resolver,
|
||||||
))
|
))
|
||||||
.layer(CookieManagerLayer::new())
|
.layer(CookieManagerLayer::new())
|
||||||
|
|||||||
@@ -43,11 +43,7 @@ pub struct FileRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FileRepository {
|
impl FileRepository {
|
||||||
pub async fn new() -> Result<Self, LoftError> {
|
pub fn new(pool: PgPool) -> Result<Self, LoftError> {
|
||||||
dotenvy::dotenv().ok();
|
|
||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
|
||||||
let pool = PgPool::connect(&database_url).await.unwrap();
|
|
||||||
|
|
||||||
let storage_path = std::env::var("STORAGE_PATH").expect("STORAGE_PATH must be set");
|
let storage_path = std::env::var("STORAGE_PATH").expect("STORAGE_PATH must be set");
|
||||||
let op = Operator::new(services::Fs::default().root(&storage_path))
|
let op = Operator::new(services::Fs::default().root(&storage_path))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -129,10 +125,10 @@ impl FileRepository {
|
|||||||
file_id
|
file_id
|
||||||
);
|
);
|
||||||
let record = self.get_file(file_id).await?;
|
let record = self.get_file(file_id).await?;
|
||||||
info!("Downloading file bytes \"{}\"", file_id);
|
info!("Deleting file bytes \"{}\"", file_id);
|
||||||
self.op.delete(&record.storage_path).await.unwrap();
|
self.op.delete(&record.storage_path).await.unwrap();
|
||||||
|
|
||||||
info!("Downloading file record \"{}\"", file_id);
|
info!("Deleting file record \"{}\"", file_id);
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
FileRecord,
|
FileRecord,
|
||||||
r#"
|
r#"
|
||||||
@@ -164,11 +160,143 @@ impl FileRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: i64,
|
||||||
|
pub username: String,
|
||||||
|
pub password_hash: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, FromRow)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: i64,
|
||||||
|
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UserRepository {
|
||||||
|
pub pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRepository {
|
||||||
|
pub fn new(pool: PgPool) -> Result<Self, LoftError> {
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(
|
||||||
|
&self,
|
||||||
|
username: String,
|
||||||
|
password_hash: String,
|
||||||
|
) -> Result<User, LoftError> {
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
User,
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (username, password_hash)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
username,
|
||||||
|
password_hash
|
||||||
|
)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("Persisted user: {}", username);
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_username(&self, username: String) -> Result<User, LoftError> {
|
||||||
|
info!("Fetching username \"{}\" from users", username);
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
User,
|
||||||
|
r#"
|
||||||
|
SELECT *
|
||||||
|
FROM users u
|
||||||
|
WHERE u.username = $1
|
||||||
|
"#,
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.ok_or(LoftError::LoginFail)?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_session(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
token: String,
|
||||||
|
expires_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> Result<Session, LoftError> {
|
||||||
|
let session = sqlx::query_as!(
|
||||||
|
Session,
|
||||||
|
r#"
|
||||||
|
INSERT INTO sessions (id, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
token,
|
||||||
|
user_id,
|
||||||
|
expires_at
|
||||||
|
)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Persisted session: {} with expiration date: {}",
|
||||||
|
token, expires_at
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_session(&self, token: String) -> Result<Session, LoftError> {
|
||||||
|
info!("Fetching session \"{}\" from sessions", token);
|
||||||
|
let session = sqlx::query_as!(
|
||||||
|
Session,
|
||||||
|
r#"
|
||||||
|
SELECT *
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.id = $1
|
||||||
|
AND s.expires_at > NOW()
|
||||||
|
"#,
|
||||||
|
token
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.ok_or(LoftError::LoginFail)?;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_session(&self, token: String) -> Result<(), LoftError> {
|
||||||
|
sqlx::query!(r#"DELETE FROM sessions WHERE id = $1"#, token)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("Deleted session \"{}\"", token);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use rand::RngExt;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
async fn truncate(pool: &PgPool) {
|
async fn truncate_file_records(pool: &PgPool) {
|
||||||
sqlx::query!("TRUNCATE TABLE file_records")
|
sqlx::query!("TRUNCATE TABLE file_records")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
@@ -176,14 +304,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn file_repository() -> Result<FileRepository, LoftError> {
|
async fn file_repository() -> Result<FileRepository, LoftError> {
|
||||||
Ok(FileRepository::new().await.unwrap())
|
dotenvy::dotenv().ok();
|
||||||
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
|
Ok(FileRepository::new(pool)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_upload_and_list() {
|
async fn test_upload_and_list() {
|
||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
truncate(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
file_repository
|
file_repository
|
||||||
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
||||||
@@ -196,14 +327,14 @@ mod tests {
|
|||||||
let files = file_repository.list_files().await.unwrap();
|
let files = file_repository.list_files().await.unwrap();
|
||||||
|
|
||||||
assert_eq!(files.len(), 2);
|
assert_eq!(files.len(), 2);
|
||||||
truncate(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_download() {
|
async fn test_download() {
|
||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
truncate(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
let uploaded = file_repository
|
let uploaded = file_repository
|
||||||
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
||||||
@@ -212,14 +343,14 @@ mod tests {
|
|||||||
let downloaded = file_repository.download_file(uploaded.id).await.unwrap();
|
let downloaded = file_repository.download_file(uploaded.id).await.unwrap();
|
||||||
|
|
||||||
assert!(!downloaded.is_empty());
|
assert!(!downloaded.is_empty());
|
||||||
truncate(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_download_not_found() {
|
async fn test_download_not_found() {
|
||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
truncate(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
file_repository.download_file(i64::MAX).await,
|
file_repository.download_file(i64::MAX).await,
|
||||||
@@ -231,7 +362,7 @@ mod tests {
|
|||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_delete() {
|
async fn test_delete() {
|
||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
truncate(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
let uploaded = file_repository
|
let uploaded = file_repository
|
||||||
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
||||||
@@ -243,7 +374,7 @@ mod tests {
|
|||||||
file_repository.download_file(uploaded.id).await,
|
file_repository.download_file(uploaded.id).await,
|
||||||
Err(LoftError::FileIdNotFound)
|
Err(LoftError::FileIdNotFound)
|
||||||
));
|
));
|
||||||
truncate(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -256,4 +387,161 @@ mod tests {
|
|||||||
Err(LoftError::FileIdNotFound)
|
Err(LoftError::FileIdNotFound)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn truncate_users(pool: &PgPool) {
|
||||||
|
sqlx::query!("TRUNCATE TABLE users CASCADE")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn truncate_sessions(pool: &PgPool) {
|
||||||
|
sqlx::query!("TRUNCATE TABLE sessions")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_repository() -> Result<UserRepository, LoftError> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
|
Ok(UserRepository::new(pool)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_create_user() {
|
||||||
|
let user_repository = user_repository().await.unwrap();
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
|
||||||
|
let username = "picolo".to_string();
|
||||||
|
let user = user_repository
|
||||||
|
.create_user(username.clone(), "pw".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(user.username, username);
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_find_by_username() {
|
||||||
|
let user_repository = user_repository().await.unwrap();
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
|
||||||
|
let username = "picolo".to_string();
|
||||||
|
user_repository
|
||||||
|
.create_user(username.clone(), "pw".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let fetched_user = user_repository
|
||||||
|
.find_by_username(username.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fetched_user.username, username);
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token() -> String {
|
||||||
|
rand::rng()
|
||||||
|
.sample_iter(&rand::distr::Alphanumeric)
|
||||||
|
.take(64)
|
||||||
|
.map(char::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_create_session() {
|
||||||
|
let user_repository = user_repository().await.unwrap();
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
|
||||||
|
let user = user_repository
|
||||||
|
.create_user("picolo".to_string(), "pw".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let token = token();
|
||||||
|
|
||||||
|
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(2);
|
||||||
|
let session = user_repository
|
||||||
|
.create_session(user.id, token.clone(), expires_at)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(session.id, token);
|
||||||
|
assert_eq!(session.user_id, user.id);
|
||||||
|
assert!(expires_at > chrono::Utc::now());
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_get_session() {
|
||||||
|
let user_repository = user_repository().await.unwrap();
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
|
||||||
|
let user = user_repository
|
||||||
|
.create_user("picolo".to_string(), "pw".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let token = token();
|
||||||
|
|
||||||
|
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(2);
|
||||||
|
user_repository
|
||||||
|
.create_session(user.id, token.clone(), expires_at)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let fetched_session = user_repository.get_session(token.clone()).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fetched_session.id, token);
|
||||||
|
assert_eq!(fetched_session.user_id, user.id);
|
||||||
|
assert!(fetched_session.expires_at > chrono::Utc::now());
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_delete_session() {
|
||||||
|
let user_repository = user_repository().await.unwrap();
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
|
||||||
|
let user = user_repository
|
||||||
|
.create_user("picolo".to_string(), "pw".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let token = token();
|
||||||
|
|
||||||
|
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(2);
|
||||||
|
let session = user_repository
|
||||||
|
.create_session(user.id, token.clone(), expires_at)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(session.id, token);
|
||||||
|
assert_eq!(session.user_id, user.id);
|
||||||
|
assert!(expires_at > chrono::Utc::now());
|
||||||
|
|
||||||
|
user_repository.delete_session(token.clone()).await.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
user_repository.get_session(token.clone()).await,
|
||||||
|
Err(LoftError::LoginFail)
|
||||||
|
));
|
||||||
|
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{FromRequestParts, Request},
|
extract::{FromRequestParts, Request, State},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use lazy_regex::regex_captures;
|
use tower_cookies::Cookies;
|
||||||
use tower_cookies::{Cookie, Cookies};
|
|
||||||
|
|
||||||
use crate::{ctx::Ctx, error::LoftError, web::AUTH_TOKEN};
|
use crate::{ctx::Ctx, error::LoftError, model::UserRepository, web::AUTH_TOKEN};
|
||||||
|
|
||||||
/// validates the cookie exists and is well-formed (3-part format)
|
|
||||||
pub async fn mw_require_auth(
|
pub async fn mw_require_auth(
|
||||||
ctx: Result<Ctx, LoftError>,
|
ctx: Result<Ctx, LoftError>,
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -20,35 +18,27 @@ pub async fn mw_require_auth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mw_ctx_resolver(
|
pub async fn mw_ctx_resolver(
|
||||||
|
State(user_repository): State<UserRepository>,
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
mut req: Request,
|
mut req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, LoftError> {
|
) -> Result<Response, LoftError> {
|
||||||
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
|
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
|
||||||
let result_ctx = match auth_token
|
|
||||||
.ok_or(LoftError::AuthFailNoAuthTokenCookie)
|
let result_ctx = match auth_token {
|
||||||
.and_then(parse_auth_token)
|
Some(token) => user_repository
|
||||||
{
|
.get_session(token)
|
||||||
Ok((user_id, _, _)) => {
|
.await
|
||||||
//TODO: add validation
|
.map(|s| Ctx::new(s.user_id)),
|
||||||
Ok(Ctx::new(user_id))
|
None => Err(LoftError::AuthFailNoAuthTokenCookie),
|
||||||
}
|
|
||||||
Err(e) => Err(e),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if result_ctx.is_err() && !matches!(result_ctx, Err(LoftError::AuthFailNoAuthTokenCookie)) {
|
|
||||||
cookies.remove(Cookie::from(AUTH_TOKEN))
|
|
||||||
}
|
|
||||||
|
|
||||||
req.extensions_mut().insert(result_ctx);
|
req.extensions_mut().insert(result_ctx);
|
||||||
|
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S: Send + Sync> FromRequestParts<S> for Ctx {
|
impl<S: Send + Sync> FromRequestParts<S> for Ctx {
|
||||||
type Rejection = LoftError;
|
type Rejection = LoftError;
|
||||||
|
|
||||||
// extracts user_id from the token and makes it available to handlers as an extractor
|
|
||||||
fn from_request_parts(
|
fn from_request_parts(
|
||||||
parts: &mut axum::http::request::Parts,
|
parts: &mut axum::http::request::Parts,
|
||||||
_: &S,
|
_: &S,
|
||||||
@@ -62,18 +52,3 @@ impl<S: Send + Sync> FromRequestParts<S> for Ctx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_auth_token(auth_token: String) -> Result<(u64, u64, String), LoftError> {
|
|
||||||
let (_, user_id, expiration, signature) =
|
|
||||||
regex_captures!(r"^user-(\d+)\.(\d+)\.([a-f0-9]+)$", &auth_token)
|
|
||||||
.ok_or(LoftError::AuthFailTokenWrongFormat)?;
|
|
||||||
|
|
||||||
let user_id: u64 = user_id
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| LoftError::AuthFailTokenWrongFormat)?;
|
|
||||||
let expiration: u64 = expiration
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| LoftError::AuthFailTokenWrongFormat)?;
|
|
||||||
|
|
||||||
Ok((user_id, expiration, signature.to_string()))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -93,36 +93,71 @@ mod tests {
|
|||||||
TestServer,
|
TestServer,
|
||||||
multipart::{MultipartForm, Part},
|
multipart::{MultipartForm, Part},
|
||||||
};
|
};
|
||||||
|
use rand::RngExt;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_cookies::CookieManagerLayer;
|
use tower_cookies::CookieManagerLayer;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::FileRepository,
|
model::{FileRepository, UserRepository},
|
||||||
web::{
|
web::{
|
||||||
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
||||||
routes_file::routes_file,
|
routes_file::routes_file,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cookie format: user-[user-id].[expiration].[signature]
|
const BAD_AUTH_COOKIE: &str = "auth-token=user-0123456789";
|
||||||
const AUTH_COOKIE: &str = "auth-token=user-1.0123456789.a1b2c3d4e5f6";
|
|
||||||
const BAD_AUTH_COOKIE: &str = "auth-token=user-1.0123456789";
|
async fn file_repository() -> FileRepository {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
|
let file_repository = FileRepository::new(pool).unwrap();
|
||||||
|
file_repository
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_repository() -> UserRepository {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
|
let user_repository = UserRepository::new(pool).unwrap();
|
||||||
|
user_repository
|
||||||
|
}
|
||||||
|
|
||||||
async fn test_server() -> TestServer {
|
async fn test_server() -> TestServer {
|
||||||
let file_repository = FileRepository::new().await.unwrap();
|
let user_repository = user_repository().await;
|
||||||
|
let file_repository = file_repository().await;
|
||||||
let routes_file =
|
let routes_file =
|
||||||
routes_file(file_repository.clone()).route_layer(middleware::from_fn(mw_require_auth));
|
routes_file(file_repository.clone()).route_layer(middleware::from_fn(mw_require_auth));
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", routes_file)
|
.nest("/api", routes_file)
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
file_repository,
|
user_repository,
|
||||||
mw_ctx_resolver,
|
mw_ctx_resolver,
|
||||||
))
|
))
|
||||||
.layer(CookieManagerLayer::new());
|
.layer(CookieManagerLayer::new());
|
||||||
|
|
||||||
TestServer::new(app)
|
TestServer::new(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_test_session(user_repository: &UserRepository) -> String {
|
||||||
|
let user = user_repository
|
||||||
|
.create_user("testuser".to_string(), "hash".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let token: String = rand::rng()
|
||||||
|
.sample_iter(&rand::distr::Alphanumeric)
|
||||||
|
.take(64)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
let expires_at = chrono::Utc::now() + chrono::Duration::days(1);
|
||||||
|
user_repository
|
||||||
|
.create_session(user.id, token.clone(), expires_at)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
token
|
||||||
|
}
|
||||||
|
|
||||||
async fn truncate(pool: &PgPool) {
|
async fn truncate(pool: &PgPool) {
|
||||||
sqlx::query!("TRUNCATE TABLE file_records")
|
sqlx::query!("TRUNCATE TABLE file_records")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -149,7 +184,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_requires_auth_post() {
|
async fn test_requires_auth_post() {
|
||||||
let file_repository = FileRepository::new().await.unwrap();
|
let file_repository = file_repository().await;
|
||||||
truncate(&file_repository.pool).await;
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
@@ -167,13 +202,15 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_list_files_empty() {
|
async fn test_list_files_empty() {
|
||||||
let file_repository = FileRepository::new().await.unwrap();
|
let user_repository = user_repository().await;
|
||||||
|
let token = create_test_session(&user_repository).await;
|
||||||
|
let file_repository = file_repository().await;
|
||||||
truncate(&file_repository.pool).await;
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
server
|
server
|
||||||
.get("/api/files")
|
.get("/api/files")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
|
||||||
.await
|
.await
|
||||||
.assert_status_ok()
|
.assert_status_ok()
|
||||||
.assert_json(&json!([]));
|
.assert_json(&json!([]));
|
||||||
@@ -182,13 +219,15 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_upload_and_list_files() {
|
async fn test_upload_and_list_files() {
|
||||||
let file_repository = FileRepository::new().await.unwrap();
|
let user_repository = user_repository().await;
|
||||||
|
let token = create_test_session(&user_repository).await;
|
||||||
|
let file_repository = file_repository().await;
|
||||||
truncate(&file_repository.pool).await;
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
let res = server
|
let res = server
|
||||||
.post("/api/files")
|
.post("/api/files")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
|
||||||
.multipart(MultipartForm::new().add_part(
|
.multipart(MultipartForm::new().add_part(
|
||||||
"file",
|
"file",
|
||||||
Part::bytes(b"fake_bytes".to_vec()).file_name("a.jpg"),
|
Part::bytes(b"fake_bytes".to_vec()).file_name("a.jpg"),
|
||||||
@@ -201,7 +240,7 @@ mod tests {
|
|||||||
|
|
||||||
let list = server
|
let list = server
|
||||||
.get("/api/files")
|
.get("/api/files")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
|
||||||
.await
|
.await
|
||||||
.json::<serde_json::Value>();
|
.json::<serde_json::Value>();
|
||||||
|
|
||||||
@@ -212,13 +251,15 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_download_file() {
|
async fn test_download_file() {
|
||||||
let file_repository = FileRepository::new().await.unwrap();
|
let user_repository = user_repository().await;
|
||||||
|
let token = create_test_session(&user_repository).await;
|
||||||
|
let file_repository = file_repository().await;
|
||||||
truncate(&file_repository.pool).await;
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
let post_res = server
|
let post_res = server
|
||||||
.post("/api/files")
|
.post("/api/files")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
|
||||||
.multipart(MultipartForm::new().add_part(
|
.multipart(MultipartForm::new().add_part(
|
||||||
"file",
|
"file",
|
||||||
Part::bytes(b"fake_bytes".to_vec()).file_name("a.jpg"),
|
Part::bytes(b"fake_bytes".to_vec()).file_name("a.jpg"),
|
||||||
@@ -227,7 +268,7 @@ mod tests {
|
|||||||
let id = post_res.json::<serde_json::Value>()["id"].as_i64().unwrap();
|
let id = post_res.json::<serde_json::Value>()["id"].as_i64().unwrap();
|
||||||
let res = server
|
let res = server
|
||||||
.get(&format!("/api/files/{id}/download"))
|
.get(&format!("/api/files/{id}/download"))
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
res.assert_status_ok();
|
res.assert_status_ok();
|
||||||
@@ -238,10 +279,12 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_download_file_not_found() {
|
async fn test_download_file_not_found() {
|
||||||
|
let user_repository = user_repository().await;
|
||||||
|
let token = create_test_session(&user_repository).await;
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
server
|
server
|
||||||
.get("/api/files/99/download")
|
.get("/api/files/99/download")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
|
||||||
.await
|
.await
|
||||||
.assert_status_not_found();
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
@@ -249,13 +292,15 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_delete_file() {
|
async fn test_delete_file() {
|
||||||
let file_repository = FileRepository::new().await.unwrap();
|
let user_repository = user_repository().await;
|
||||||
|
let token = create_test_session(&user_repository).await;
|
||||||
|
let file_repository = file_repository().await;
|
||||||
truncate(&file_repository.pool).await;
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
let post_res = server
|
let post_res = server
|
||||||
.post("/api/files")
|
.post("/api/files")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
|
||||||
.multipart(MultipartForm::new().add_part(
|
.multipart(MultipartForm::new().add_part(
|
||||||
"file",
|
"file",
|
||||||
Part::bytes(b"fake_bytes".to_vec()).file_name("a.jpg"),
|
Part::bytes(b"fake_bytes".to_vec()).file_name("a.jpg"),
|
||||||
@@ -265,13 +310,13 @@ mod tests {
|
|||||||
|
|
||||||
server
|
server
|
||||||
.delete(&format!("/api/files/{id}"))
|
.delete(&format!("/api/files/{id}"))
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
|
||||||
.await
|
.await
|
||||||
.assert_status_ok();
|
.assert_status_ok();
|
||||||
|
|
||||||
server
|
server
|
||||||
.get(&format!("/api/files/{id}"))
|
.get(&format!("/api/files/{id}"))
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
|
||||||
.await
|
.await
|
||||||
.assert_status_not_found();
|
.assert_status_not_found();
|
||||||
|
|
||||||
@@ -281,10 +326,12 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_delete_file_not_found() {
|
async fn test_delete_file_not_found() {
|
||||||
|
let user_repository = user_repository().await;
|
||||||
|
let token = create_test_session(&user_repository).await;
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
server
|
server
|
||||||
.delete("/api/files/99")
|
.delete("/api/files/99")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
|
||||||
.await
|
.await
|
||||||
.assert_status_not_found();
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,103 @@
|
|||||||
use axum::{
|
use argon2::{
|
||||||
Json, Router,
|
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||||
routing::{get, post},
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
};
|
};
|
||||||
|
use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
|
||||||
|
use rand::RngExt;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use sqlx::PgPool;
|
||||||
use tower_cookies::{Cookie, Cookies};
|
use tower_cookies::{Cookie, Cookies};
|
||||||
|
|
||||||
use crate::{error::LoftError, web::AUTH_TOKEN};
|
use crate::{error::LoftError, model::UserRepository, web::AUTH_TOKEN};
|
||||||
|
|
||||||
pub fn routes_login() -> Router {
|
pub fn routes_auth(user_repository: UserRepository) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", post(login))
|
.route("/login", post(login))
|
||||||
.route("/register", get(register))
|
.route("/logout", post(logout))
|
||||||
|
.route("/register", post(register))
|
||||||
|
.with_state(user_repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login(
|
async fn login(
|
||||||
|
State(user_repository): State<UserRepository>,
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
Json(payload): Json<LoginPayload>,
|
Json(payload): Json<LoginPayload>,
|
||||||
) -> Result<Json<Value>, LoftError> {
|
) -> Result<StatusCode, LoftError> {
|
||||||
//TODO: real db/auth logic
|
let user = user_repository.find_by_username(payload.username).await?;
|
||||||
if payload.username != "x" || payload.password != "y" {
|
// TODO: replace unwrap with ?
|
||||||
|
let parsed_hash = PasswordHash::new(&user.password_hash).unwrap();
|
||||||
|
if Argon2::default()
|
||||||
|
.verify_password(payload.password.as_bytes(), &parsed_hash)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let expires_at = chrono::Utc::now() + chrono::Duration::days(1);
|
||||||
|
let cookie = create_cookie();
|
||||||
|
let auth_token = cookie.value().to_string();
|
||||||
|
cookies.add(cookie);
|
||||||
|
user_repository
|
||||||
|
.create_session(user.id, auth_token, expires_at)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
return Err(LoftError::LoginFail);
|
return Err(LoftError::LoginFail);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: real auth-token generation-signature
|
Ok(StatusCode::OK)
|
||||||
cookies.add(Cookie::new(AUTH_TOKEN, "user-1.exp.sign"));
|
|
||||||
|
|
||||||
let body = Json(json!({
|
|
||||||
"result": {
|
|
||||||
"success": true
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
Ok(body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn register() -> &'static str {
|
async fn logout(
|
||||||
"register"
|
State(user_repository): State<UserRepository>,
|
||||||
|
cookies: Cookies,
|
||||||
|
) -> Result<StatusCode, LoftError> {
|
||||||
|
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
|
||||||
|
|
||||||
|
if let Some(auth_token) = auth_token {
|
||||||
|
user_repository.delete_session(auth_token.clone()).await?;
|
||||||
|
cookies.remove(Cookie::build(AUTH_TOKEN).path("/").build());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register(
|
||||||
|
State(user_repository): State<UserRepository>,
|
||||||
|
Json(payload): Json<LoginPayload>,
|
||||||
|
) -> Result<StatusCode, LoftError> {
|
||||||
|
if user_repository
|
||||||
|
.find_by_username(payload.username.clone())
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Err(LoftError::RegisterFail);
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
// TODO: replace unwrap
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(payload.password.as_bytes(), &salt)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
user_repository
|
||||||
|
.create_user(payload.username, password_hash)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_cookie() -> Cookie<'static> {
|
||||||
|
let auth_token: String = rand::rng()
|
||||||
|
.sample_iter(&rand::distr::Alphanumeric)
|
||||||
|
.take(64)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Cookie::build((AUTH_TOKEN, auth_token))
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(tower_cookies::cookie::SameSite::Lax)
|
||||||
|
.path("/")
|
||||||
|
.secure(std::env::var("ENVIRONMENT").unwrap_or_default() == "prod")
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -46,23 +108,54 @@ struct LoginPayload {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use axum::{
|
use axum::{Router, http::StatusCode};
|
||||||
Router,
|
|
||||||
routing::{get, post},
|
|
||||||
};
|
|
||||||
use axum_test::TestServer;
|
use axum_test::TestServer;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::web::routes_login::{login, register};
|
use crate::{
|
||||||
|
model::UserRepository,
|
||||||
|
web::{AUTH_TOKEN, routes_login::routes_auth},
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn truncate_users(pool: &PgPool) {
|
||||||
|
sqlx::query!("TRUNCATE TABLE users CASCADE")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn truncate_sessions(pool: &PgPool) {
|
||||||
|
sqlx::query!("TRUNCATE TABLE sessions")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_repository() -> UserRepository {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
|
let user_repository = UserRepository::new(pool).unwrap();
|
||||||
|
user_repository
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_server() -> TestServer {
|
||||||
|
let user_repository = user_repository().await;
|
||||||
|
let routes_auth = routes_auth(user_repository);
|
||||||
|
let app = Router::new()
|
||||||
|
.nest("/api/auth", routes_auth)
|
||||||
|
.layer(tower_cookies::CookieManagerLayer::new());
|
||||||
|
|
||||||
|
TestServer::new(app)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_routes_login_wrong_credentials() {
|
async fn test_routes_login_wrong_credentials() {
|
||||||
let app = Router::new()
|
let server = test_server().await;
|
||||||
.route(&"/login", post(login))
|
|
||||||
.layer(tower_cookies::CookieManagerLayer::new());
|
|
||||||
let server = TestServer::new(app);
|
|
||||||
let response = server
|
let response = server
|
||||||
.post("/login")
|
.post("/api/auth/login")
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"username": "wrong",
|
"username": "wrong",
|
||||||
"password": "wrong",
|
"password": "wrong",
|
||||||
@@ -72,30 +165,74 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_routes_login() {
|
async fn test_routes_login() {
|
||||||
let app = Router::new()
|
let user_repository = user_repository().await;
|
||||||
.route(&"/login", post(login))
|
truncate_sessions(&user_repository.pool).await;
|
||||||
.layer(tower_cookies::CookieManagerLayer::new());
|
truncate_users(&user_repository.pool).await;
|
||||||
let server = TestServer::new(app);
|
let server = test_server().await;
|
||||||
|
|
||||||
|
server
|
||||||
|
.post("/api/auth/register")
|
||||||
|
.json(&json!({ "username": "picolo", "password": "picolo" }))
|
||||||
|
.await;
|
||||||
|
|
||||||
let response = server
|
let response = server
|
||||||
.post("/login")
|
.post("/api/auth/login")
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"username": "x",
|
"username": "picolo",
|
||||||
"password": "y",
|
"password": "picolo",
|
||||||
}))
|
}))
|
||||||
.await;
|
.await;
|
||||||
response.assert_status_ok().assert_json(&json!({
|
|
||||||
"result": {
|
response.assert_status_ok();
|
||||||
"success": true
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_routes_logout() {
|
||||||
|
let user_repository = user_repository().await;
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
|
let server = test_server().await;
|
||||||
|
|
||||||
|
let register_response = server
|
||||||
|
.post("/api/auth/register")
|
||||||
|
.json(&json!({ "username": "picolo", "password": "picolo" }))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
register_response.assert_status(StatusCode::CREATED);
|
||||||
|
|
||||||
|
let login_response = server
|
||||||
|
.post("/api/auth/login")
|
||||||
|
.json(&json!({
|
||||||
|
"username": "picolo",
|
||||||
|
"password": "picolo",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
login_response.assert_status_ok();
|
||||||
|
|
||||||
|
let cookie = login_response.cookie(AUTH_TOKEN);
|
||||||
|
|
||||||
|
let logout_response = server.post("/api/auth/logout").add_cookie(cookie).await;
|
||||||
|
|
||||||
|
logout_response.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_routes_register() {
|
async fn test_routes_register() {
|
||||||
let app = Router::new().route(&"/register", get(register));
|
let user_repository = user_repository().await;
|
||||||
let server = TestServer::new(app);
|
truncate_sessions(&user_repository.pool).await;
|
||||||
let response = server.get("/register").await;
|
truncate_users(&user_repository.pool).await;
|
||||||
response.assert_status_ok().assert_text("register");
|
let server = test_server().await;
|
||||||
|
|
||||||
|
let register_response = server
|
||||||
|
.post("/api/auth/register")
|
||||||
|
.json(&json!({ "username": "picolo", "password": "picolo" }))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
register_response.assert_status(StatusCode::CREATED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatName(name: string): string {
|
function formatName(name: string): string {
|
||||||
if (name.length > 50) {
|
if (name.length > 65) {
|
||||||
name = name.slice(0, 50).concat("...");
|
name = name.slice(0, 65).concat("...");
|
||||||
}
|
}
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full px-16 py-8">
|
<div class="w-full px-4 py-8">
|
||||||
<h1 class="text-4xl text-white mb-6" style="font-family: 'Caveat', cursive;">rafi</h1>
|
<h1 class="text-4xl text-white mb-6" style="font-family: 'Caveat', cursive;">rafi</h1>
|
||||||
|
|
||||||
<div class="flex justify-center mb-4">
|
<div class="flex justify-center mb-4">
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="file" bind:this={fileInput} onchange={uploadFile} class="hidden" />
|
<input type="file" bind:this={fileInput} onchange={uploadFile} class="hidden" />
|
||||||
<div class="flex justify-start mb-1">
|
<div class="mb-1">
|
||||||
<button class="px-2 py-1 border border-sky-200/20 text-white/60 hover:border-sky-200/40 text-sm transition-colors rounded-sm cursor-pointer" onclick={openFilePicker}>
|
<button class="px-2 py-1 border border-sky-200/20 text-white/60 hover:border-sky-200/40 text-sm transition-colors rounded-sm cursor-pointer" onclick={openFilePicker}>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user