Compare commits
5 Commits
e7eaa42064
...
c310d69b11
| Author | SHA1 | Date | |
|---|---|---|---|
| c310d69b11 | |||
| 5cc384b3c0 | |||
| 2501f6ca67 | |||
| 70f839698a | |||
| 24b1344d06 |
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -190,7 +190,9 @@ dependencies = [
|
|||||||
"axum-test",
|
"axum-test",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"futures-util",
|
||||||
"lazy-regex",
|
"lazy-regex",
|
||||||
|
"mime_guess",
|
||||||
"opendal",
|
"opendal",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ 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"
|
argon2 = "0.5.3"
|
||||||
rand = "0.10.1"
|
rand = "0.10.1"
|
||||||
|
futures-util = "0.3.32"
|
||||||
|
mime_guess = "2.0.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
axum-test = "20.0.0"
|
axum-test = "20.0.0"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE file_records ADD COLUMN user_id BIGINT NOT NULL REFERENCES users(id);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE file_records RENAME COLUMN storage_path TO storage_key;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
use axum::{body::Bytes, extract::multipart::MultipartError};
|
||||||
|
use futures_util::{Stream, StreamExt};
|
||||||
use opendal::{Operator, layers::LoggingLayer, services};
|
use opendal::{Operator, layers::LoggingLayer, services};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{PgPool, prelude::FromRow, types::uuid};
|
use sqlx::{PgPool, prelude::FromRow};
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
@@ -10,12 +12,12 @@ use crate::error::LoftError;
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FileRecord {
|
pub struct FileRecord {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
// pub user_id: i64,
|
pub user_id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub file_type: String,
|
pub file_type: String,
|
||||||
pub size: i64,
|
pub size: i64,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
pub storage_path: String,
|
pub storage_key: String,
|
||||||
pub uploaded_at: chrono::DateTime<chrono::Utc>,
|
pub uploaded_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,45 +58,63 @@ impl FileRepository {
|
|||||||
|
|
||||||
pub async fn upload_file(
|
pub async fn upload_file(
|
||||||
&self,
|
&self,
|
||||||
bytes: Vec<u8>,
|
mut file_byte_stream: impl Stream<Item = Result<Bytes, MultipartError>> + Unpin,
|
||||||
file_name: String,
|
file_storage_key: &str,
|
||||||
|
) -> Result<usize, LoftError> {
|
||||||
|
let mut writer = self.op.writer(&file_storage_key).await.unwrap();
|
||||||
|
let mut total_size = 0;
|
||||||
|
while let Some(chunk) = file_byte_stream.next().await {
|
||||||
|
let chunk = chunk.unwrap();
|
||||||
|
total_size += chunk.len();
|
||||||
|
writer.write(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
// must
|
||||||
|
writer.close().await.unwrap();
|
||||||
|
Ok(total_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_file_record(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
file_name: &str,
|
||||||
|
file_size: usize,
|
||||||
|
file_storage_key: &str,
|
||||||
) -> Result<FileRecord, LoftError> {
|
) -> Result<FileRecord, LoftError> {
|
||||||
let storage_path_name = format!("{}-{}", file_name, uuid::Uuid::new_v4());
|
|
||||||
let bytes_length = bytes.len();
|
|
||||||
|
|
||||||
info!("Uploading file \"{}\"", file_name);
|
|
||||||
self.op.write(&storage_path_name, bytes).await.unwrap();
|
|
||||||
|
|
||||||
info!("Saving metadata of file \"{}\" in file_records", file_name);
|
info!("Saving metadata of file \"{}\" in file_records", file_name);
|
||||||
let file = sqlx::query_as!(
|
let file_record = sqlx::query_as!(
|
||||||
FileRecord,
|
FileRecord,
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO file_records (name, file_type, size, storage_path)
|
INSERT INTO file_records (user_id, name, file_type, size, storage_key)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
|
user_id,
|
||||||
file_name,
|
file_name,
|
||||||
"TODO-file_type".to_string(),
|
mime_guess::from_path(file_name).first_or_octet_stream().to_string(),
|
||||||
bytes_length as i64,
|
file_size as i64,
|
||||||
storage_path_name
|
file_storage_key
|
||||||
)
|
)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
Ok(file)
|
Ok(file_record)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_file(&self, file_id: i64) -> Result<Vec<u8>, LoftError> {
|
pub async fn download_file(
|
||||||
|
&self,
|
||||||
|
file_id: i64,
|
||||||
|
) -> Result<impl Stream<Item = std::io::Result<Bytes>> + use<>, LoftError> {
|
||||||
info!(
|
info!(
|
||||||
"Fetching metadata of file \"{}\" from file_records",
|
"Fetching metadata of file \"{}\" from file_records",
|
||||||
file_id
|
file_id
|
||||||
);
|
);
|
||||||
let record = self.get_file(file_id).await?;
|
let record = self.get_file(file_id).await?;
|
||||||
info!("Downloading file \"{}\"", file_id);
|
info!("Downloading file \"{}\"", file_id);
|
||||||
let bytes = self.op.read(&record.storage_path).await.unwrap();
|
let reader = self.op.reader(&record.storage_key).await.unwrap();
|
||||||
|
let stream = reader.into_bytes_stream(0..).await.unwrap();
|
||||||
|
|
||||||
Ok(bytes.to_vec())
|
Ok(stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_file(&self, file_id: i64) -> Result<FileRecord, LoftError> {
|
pub async fn get_file(&self, file_id: i64) -> Result<FileRecord, LoftError> {
|
||||||
@@ -126,7 +146,7 @@ impl FileRepository {
|
|||||||
);
|
);
|
||||||
let record = self.get_file(file_id).await?;
|
let record = self.get_file(file_id).await?;
|
||||||
info!("Deleting file bytes \"{}\"", file_id);
|
info!("Deleting file bytes \"{}\"", file_id);
|
||||||
self.op.delete(&record.storage_path).await.unwrap();
|
self.op.delete(&record.storage_key).await.unwrap();
|
||||||
|
|
||||||
info!("Deleting file record \"{}\"", file_id);
|
info!("Deleting file record \"{}\"", file_id);
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
@@ -144,13 +164,15 @@ impl FileRepository {
|
|||||||
.ok_or(LoftError::FileIdNotFound)
|
.ok_or(LoftError::FileIdNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_files(&self) -> Result<Vec<FileRecord>, LoftError> {
|
pub async fn list_files(&self, user_id: i64) -> Result<Vec<FileRecord>, LoftError> {
|
||||||
let files = sqlx::query_as!(
|
let files = sqlx::query_as!(
|
||||||
FileRecord,
|
FileRecord,
|
||||||
r#"
|
r#"
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM file_records fr
|
FROM file_records fr
|
||||||
"#
|
WHERE user_id = $1
|
||||||
|
"#,
|
||||||
|
user_id
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -292,7 +314,9 @@ impl UserRepository {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use futures_util::stream;
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
|
use sqlx::types::uuid;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -304,7 +328,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn file_repository() -> Result<FileRepository, LoftError> {
|
async fn file_repository() -> Result<FileRepository, LoftError> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::from_filename(".env.test").ok();
|
||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let pool = PgPool::connect(&database_url).await.unwrap();
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
Ok(FileRepository::new(pool)?)
|
Ok(FileRepository::new(pool)?)
|
||||||
@@ -313,37 +337,71 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_upload_and_list() {
|
async fn test_upload_and_list() {
|
||||||
|
let user_repository = user_repository().await.unwrap();
|
||||||
|
let user1 = user_repository.create_user("username1".to_string(), "password_hash".to_string()).await.unwrap();
|
||||||
|
let user2 = user_repository.create_user("username2".to_string(), "password_hash".to_string()).await.unwrap();
|
||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
truncate_file_records(&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(
|
||||||
|
stream::iter(vec![Ok(Bytes::from(vec![0u8; 10]))]),
|
||||||
|
"a.jpg-uuid",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
file_repository
|
file_repository
|
||||||
.upload_file(vec![0u8; 10], "b.jpg".to_string())
|
.upload_file(
|
||||||
|
stream::iter(vec![Ok(Bytes::from(vec![0u8; 10]))]),
|
||||||
|
"b.jpg-uuid",
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let files = file_repository.list_files().await.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(files.len(), 2);
|
file_repository
|
||||||
|
.create_file_record(user1.id, "a.png", 2, "a.jpg-uuid")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
file_repository
|
||||||
|
.create_file_record(user2.id, "b.png", 2, "b.jpg-uuid")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let files = file_repository.list_files(user1.id).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(files.len(), 1);
|
||||||
truncate_file_records(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_download() {
|
async fn test_download() {
|
||||||
|
let user_repository = user_repository().await.unwrap();
|
||||||
|
let user = user_repository.create_user("username".to_string(), "password_hash".to_string()).await.unwrap();
|
||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
truncate_file_records(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
let uploaded = file_repository
|
let file_name = "a.png";
|
||||||
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
let file_storage_key = format!("{}-{}", file_name, uuid::Uuid::new_v4());
|
||||||
|
let file_size = file_repository
|
||||||
|
.upload_file(
|
||||||
|
stream::iter(vec![Ok(Bytes::from(vec![0u8; 10]))]),
|
||||||
|
&file_storage_key,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let downloaded = file_repository.download_file(uploaded.id).await.unwrap();
|
|
||||||
|
|
||||||
assert!(!downloaded.is_empty());
|
let file_record = file_repository
|
||||||
|
.create_file_record(user.id, "a.jpg", file_size, &file_storage_key)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let _downloaded = file_repository.download_file(file_record.id).await.unwrap();
|
||||||
|
|
||||||
|
//TODO: add assert
|
||||||
|
|
||||||
truncate_file_records(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -361,20 +419,34 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_delete() {
|
async fn test_delete() {
|
||||||
|
let user_repository = user_repository().await.unwrap();
|
||||||
|
let user = user_repository.create_user("username".to_string(), "password_hash".to_string()).await.unwrap();
|
||||||
let file_repository = file_repository().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
truncate_file_records(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
|
||||||
let uploaded = file_repository
|
let file_name = "a.png";
|
||||||
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
let file_storage_key = format!("{}-{}", file_name, uuid::Uuid::new_v4());
|
||||||
|
let file_size = file_repository
|
||||||
|
.upload_file(
|
||||||
|
stream::iter(vec![Ok(Bytes::from(vec![0u8; 10]))]),
|
||||||
|
&file_storage_key,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
file_repository.delete_file(uploaded.id).await.unwrap();
|
|
||||||
|
let file_record = file_repository
|
||||||
|
.create_file_record(user.id, "a.jpg", file_size, &file_storage_key)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
file_repository.delete_file(file_record.id).await.unwrap();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
file_repository.download_file(uploaded.id).await,
|
file_repository.download_file(file_record.id).await,
|
||||||
Err(LoftError::FileIdNotFound)
|
Err(LoftError::FileIdNotFound)
|
||||||
));
|
));
|
||||||
truncate_file_records(&file_repository.pool).await;
|
truncate_file_records(&file_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -403,7 +475,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn user_repository() -> Result<UserRepository, LoftError> {
|
async fn user_repository() -> Result<UserRepository, LoftError> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::from_filename(".env.test").ok();
|
||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let pool = PgPool::connect(&database_url).await.unwrap();
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
Ok(UserRepository::new(pool)?)
|
Ok(UserRepository::new(pool)?)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
|
body::Body,
|
||||||
extract::{Multipart, Path, State},
|
extract::{Multipart, Path, State},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
|
use sqlx::types::uuid;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::LoftError,
|
ctx::Ctx, error::LoftError, model::{FileRecord, FileRepository}
|
||||||
model::{FileRecord, FileRepository},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes_file(file_repository: FileRepository) -> Router {
|
pub fn routes_file(file_repository: FileRepository) -> Router {
|
||||||
@@ -21,28 +22,31 @@ pub fn routes_file(file_repository: FileRepository) -> Router {
|
|||||||
|
|
||||||
async fn upload_file(
|
async fn upload_file(
|
||||||
State(file_repository): State<FileRepository>,
|
State(file_repository): State<FileRepository>,
|
||||||
|
ctx: Ctx,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<FileRecord>, LoftError> {
|
) -> Result<Json<FileRecord>, LoftError> {
|
||||||
info!("handler: upload_file");
|
info!("handler: upload_file");
|
||||||
|
|
||||||
let mut file_name = None;
|
let mut file_name = None;
|
||||||
let mut file_type: Option<String> = None;
|
let mut file_storage_key = None;
|
||||||
let mut bytes = None;
|
let mut file_size = None;
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await.unwrap() {
|
while let Some(field) = multipart.next_field().await.unwrap() {
|
||||||
match field.name().unwrap() {
|
if field.name().unwrap() == "file" {
|
||||||
// "file_type" => file_type = Some(field.text().await.unwrap().parse().unwrap()),
|
let name = field.file_name().map(|s| s.to_string()).unwrap_or_default();
|
||||||
"file" => {
|
let key = format!("{}-{}", name, uuid::Uuid::new_v4());
|
||||||
file_name = field.file_name().map(|s| s.to_string());
|
let size = file_repository.upload_file(field, &key).await?;
|
||||||
bytes = Some(field.bytes().await.unwrap().to_vec())
|
file_name = Some(name);
|
||||||
}
|
file_storage_key = Some(key);
|
||||||
_ => (),
|
file_size = Some(size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let (Some(bytes), Some(file_name)) = (bytes, file_name) {
|
if let (Some(name), Some(key), Some(size)) = (file_name, file_storage_key, file_size) {
|
||||||
let file = file_repository.upload_file(bytes, file_name).await?;
|
let file_record = file_repository
|
||||||
return Ok(Json(file));
|
.create_file_record(ctx.user_id(), &name, size, &key)
|
||||||
|
.await?;
|
||||||
|
return Ok(Json(file_record));
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(LoftError::UndefinedErrorType)
|
Err(LoftError::UndefinedErrorType)
|
||||||
@@ -62,8 +66,8 @@ async fn download_file(
|
|||||||
State(file_repository): State<FileRepository>,
|
State(file_repository): State<FileRepository>,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<impl IntoResponse, LoftError> {
|
) -> Result<impl IntoResponse, LoftError> {
|
||||||
let bytes = file_repository.download_file(file_id as i64).await?;
|
let stream = file_repository.download_file(file_id as i64).await?;
|
||||||
Ok(bytes)
|
Ok(Body::from_stream(stream))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_file(
|
async fn delete_file(
|
||||||
@@ -78,11 +82,11 @@ async fn delete_file(
|
|||||||
|
|
||||||
async fn list_files(
|
async fn list_files(
|
||||||
State(file_repository): State<FileRepository>,
|
State(file_repository): State<FileRepository>,
|
||||||
// can add a filters param here
|
ctx: Ctx
|
||||||
) -> Result<Json<Vec<FileRecord>>, LoftError> {
|
) -> Result<Json<Vec<FileRecord>>, LoftError> {
|
||||||
info!("handler: list_files");
|
info!("handler: list_files");
|
||||||
|
|
||||||
let files = file_repository.list_files().await?;
|
let files = file_repository.list_files(ctx.user_id()).await?;
|
||||||
Ok(Json(files))
|
Ok(Json(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +113,7 @@ mod tests {
|
|||||||
const BAD_AUTH_COOKIE: &str = "auth-token=user-0123456789";
|
const BAD_AUTH_COOKIE: &str = "auth-token=user-0123456789";
|
||||||
|
|
||||||
async fn file_repository() -> FileRepository {
|
async fn file_repository() -> FileRepository {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::from_filename(".env.test").ok();
|
||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let pool = PgPool::connect(&database_url).await.unwrap();
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
let file_repository = FileRepository::new(pool).unwrap();
|
let file_repository = FileRepository::new(pool).unwrap();
|
||||||
@@ -117,7 +121,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn user_repository() -> UserRepository {
|
async fn user_repository() -> UserRepository {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::from_filename(".env.test").ok();
|
||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let pool = PgPool::connect(&database_url).await.unwrap();
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
let user_repository = UserRepository::new(pool).unwrap();
|
let user_repository = UserRepository::new(pool).unwrap();
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use argon2::{
|
|||||||
use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
|
use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
|
||||||
use tower_cookies::{Cookie, Cookies};
|
use tower_cookies::{Cookie, Cookies};
|
||||||
|
|
||||||
use crate::{error::LoftError, model::UserRepository, web::AUTH_TOKEN};
|
use crate::{error::LoftError, model::UserRepository, web::AUTH_TOKEN};
|
||||||
@@ -118,7 +117,7 @@ mod tests {
|
|||||||
web::{AUTH_TOKEN, routes_login::routes_auth},
|
web::{AUTH_TOKEN, routes_login::routes_auth},
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn truncate_users(pool: &PgPool) {
|
async fn truncate_users(pool: &PgPool) {
|
||||||
sqlx::query!("TRUNCATE TABLE users CASCADE")
|
sqlx::query!("TRUNCATE TABLE users CASCADE")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
@@ -133,7 +132,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn user_repository() -> UserRepository {
|
async fn user_repository() -> UserRepository {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::from_filename(".env.test").ok();
|
||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let pool = PgPool::connect(&database_url).await.unwrap();
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
let user_repository = UserRepository::new(pool).unwrap();
|
let user_repository = UserRepository::new(pool).unwrap();
|
||||||
@@ -153,6 +152,9 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn test_routes_login_wrong_credentials() {
|
async fn test_routes_login_wrong_credentials() {
|
||||||
|
let user_repository = user_repository().await;
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
let response = server
|
let response = server
|
||||||
.post("/api/auth/login")
|
.post("/api/auth/login")
|
||||||
@@ -162,6 +164,8 @@ mod tests {
|
|||||||
}))
|
}))
|
||||||
.await;
|
.await;
|
||||||
response.assert_status_unauthorized();
|
response.assert_status_unauthorized();
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -186,6 +190,8 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
response.assert_status_ok();
|
response.assert_status_ok();
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -218,6 +224,8 @@ mod tests {
|
|||||||
let logout_response = server.post("/api/auth/logout").add_cookie(cookie).await;
|
let logout_response = server.post("/api/auth/logout").add_cookie(cookie).await;
|
||||||
|
|
||||||
logout_response.assert_status_ok();
|
logout_response.assert_status_ok();
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -234,5 +242,7 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
register_response.assert_status(StatusCode::CREATED);
|
register_response.assert_status(StatusCode::CREATED);
|
||||||
|
truncate_sessions(&user_repository.pool).await;
|
||||||
|
truncate_users(&user_repository.pool).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
let fileRecords = $state<FileRecord[]>([]);
|
let fileRecords = $state<FileRecord[]>([]);
|
||||||
let confirmDeleteId = $state<number | null>(null);
|
let confirmDeleteId = $state<number | null>(null);
|
||||||
|
let selectedFileId = $state<number | null>(null);
|
||||||
|
let previewFile = $state<FileRecord | null>(null);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
@@ -29,6 +31,12 @@
|
|||||||
const res = await fetch('http://localhost:3000/api/files', {
|
const res = await fetch('http://localhost:3000/api/files', {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = '/auth';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fileRecords = await res.json();
|
fileRecords = await res.json();
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -91,10 +99,30 @@
|
|||||||
if (bytes < 1000 * 1000 * 1000) return (bytes / 1000 / 1000).toFixed(1) + 'MB';
|
if (bytes < 1000 * 1000 * 1000) return (bytes / 1000 / 1000).toFixed(1) + 'MB';
|
||||||
return (bytes / 1000 / 1000 / 1000).toFixed(1) + 'GB';
|
return (bytes / 1000 / 1000 / 1000).toFixed(1) + 'GB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await fetch('http://localhost:3000/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
window.location.href = '/auth';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full px-4 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>
|
<div class="relative mb-6 flex items-center justify-center">
|
||||||
|
<h1 class="text-4xl text-white" style="font-family: 'Caveat', cursive;">loft</h1>
|
||||||
|
<button
|
||||||
|
onclick={handleLogout}
|
||||||
|
class="fixed top-4 right-4 flex items-center gap-1.5 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">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center mb-4">
|
<div class="flex justify-center mb-4">
|
||||||
<input
|
<input
|
||||||
@@ -126,7 +154,9 @@
|
|||||||
<div
|
<div
|
||||||
transition:fade={{ duration: 200 }}
|
transition:fade={{ duration: 200 }}
|
||||||
animate:flip={{ duration: 200 }}
|
animate:flip={{ duration: 200 }}
|
||||||
class="flex items-stretch border-b border-sky-200/20 border-l-2 border-l-transparent last:border-b-0 hover:bg-white/2 hover:border-l-sky-400/50 transition-all"
|
onclick={() => selectedFileId = fileRecord.id}
|
||||||
|
ondblclick={() => previewFile = fileRecord}
|
||||||
|
class="flex items-stretch border-l hover:bg-white/2 transition-all {selectedFileId === fileRecord.id ? 'border-sky-400/50 bg-white/2 ring-1 ring-inset ring-sky-400/50' : 'border-sky-200/20 border-l-transparent hover:border-l-sky-400/50'}"
|
||||||
>
|
>
|
||||||
<span class="font-medium text-white text-sm flex-1 truncate py-2 pl-6">{formatName(fileRecord.name)}</span>
|
<span class="font-medium text-white text-sm flex-1 truncate py-2 pl-6">{formatName(fileRecord.name)}</span>
|
||||||
<span class="text-sm text-white/40 w-40 py-2">{formatSize(fileRecord.size)}</span>
|
<span class="text-sm text-white/40 w-40 py-2">{formatSize(fileRecord.size)}</span>
|
||||||
@@ -188,3 +218,29 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if previewFile}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||||
|
onclick={() => previewFile = null}
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-[#0f1117] border border-sky-200/20 rounded-sm w-2/3 h-2/3 flex flex-col p-6"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<span class="text-white text-sm font-medium">{previewFile.name}</span>
|
||||||
|
<button class="text-white/40 hover:text-white transition-colors cursor-pointer" onclick={() => previewFile = null}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex items-center justify-center text-white/20 text-sm">
|
||||||
|
preview coming soon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
74
frontend/src/routes/auth/+page.svelte
Normal file
74
frontend/src/routes/auth/+page.svelte
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let activeTab = $state<'login' | 'register'>('login');
|
||||||
|
let username = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const endpoint = activeTab === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||||
|
const res = await fetch(`http://localhost:3000${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
error = 'Invalid credentials';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex-1 flex items-center justify-center -mt-16">
|
||||||
|
<div class="w-full max-w-xs">
|
||||||
|
<h1 class="text-4xl text-white mb-8" style="font-family: 'Caveat', cursive;">loft</h1>
|
||||||
|
|
||||||
|
<div class="flex mb-6 border-b border-sky-200/20">
|
||||||
|
<button
|
||||||
|
onclick={() => activeTab = 'login'}
|
||||||
|
class="flex-1 py-2 text-sm transition-colors cursor-pointer {activeTab === 'login' ? 'text-white border-b border-white -mb-px' : 'text-white/40 hover:text-white/60'}"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => activeTab = 'register'}
|
||||||
|
class="flex-1 py-2 text-sm transition-colors cursor-pointer {activeTab === 'register' ? 'text-white border-b border-white -mb-px' : 'text-white/40 hover:text-white/60'}"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="username" class="text-white/30 text-xs">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
bind:value={username}
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
class="px-4 py-2 rounded-sm !bg-[#0f1117] border border-sky-200/15 transition-colors hover:bg-black/20 hover:border-sky-200/30 text-white/80 text-sm focus:outline-none focus:border-sky-200/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="password" class="text-white/30 text-xs">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
bind:value={password}
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="px-4 py-2 rounded-sm !bg-[#0f1117] border border-sky-200/15 transition-colors hover:bg-black/20 hover:border-sky-200/30 text-white/80 text-sm focus:outline-none focus:border-sky-200/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if error}<p class="text-red-400/60 text-xs">{error}</p>{/if}
|
||||||
|
<button
|
||||||
|
onclick={handleSubmit}
|
||||||
|
class="mt-1 px-4 py-2 border border-sky-200/15 text-white/40 hover:border-sky-200/30 hover:text-white/60 text-sm transition-colors rounded-sm cursor-pointer">
|
||||||
|
{activeTab === 'login' ? 'Login' : 'Register'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -75,6 +75,21 @@ button {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
input:-webkit-autofill:active {
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px #0f1117 inset !important;
|
||||||
|
-webkit-text-fill-color: #ffffffcc !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:-moz-autofill {
|
||||||
|
background-color: #0f1117 !important;
|
||||||
|
color: #ffffffcc !important;
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
button:focus:not(:focus-visible) {
|
button:focus:not(:focus-visible) {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user