feat(backend): scope files to users with FK user_id, rename storage_path to storage_key for clarity

This commit is contained in:
2026-05-28 21:24:12 +03:00
parent 70f839698a
commit 2501f6ca67
5 changed files with 51 additions and 24 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE file_records ADD COLUMN user_id BIGINT NOT NULL REFERENCES users(id);

View File

@@ -0,0 +1 @@
ALTER TABLE file_records RENAME COLUMN storage_path TO storage_key;

View File

@@ -12,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>,
} }
@@ -75,6 +75,7 @@ impl FileRepository {
pub async fn create_file_record( pub async fn create_file_record(
&self, &self,
user_id: i64,
file_name: &str, file_name: &str,
file_size: usize, file_size: usize,
file_storage_key: &str, file_storage_key: &str,
@@ -83,10 +84,11 @@ impl FileRepository {
let file_record = 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(), "TODO-file_type".to_string(),
file_size as i64, file_size as i64,
@@ -109,7 +111,7 @@ impl FileRepository {
); );
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 reader = self.op.reader(&record.storage_path).await.unwrap(); let reader = self.op.reader(&record.storage_key).await.unwrap();
let stream = reader.into_bytes_stream(0..).await.unwrap(); let stream = reader.into_bytes_stream(0..).await.unwrap();
Ok(stream) Ok(stream)
@@ -144,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!(
@@ -162,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
@@ -333,6 +337,9 @@ 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;
@@ -352,22 +359,25 @@ mod tests {
.unwrap(); .unwrap();
file_repository file_repository
.create_file_record("a.png", 2, "a.jpg-uuid") .create_file_record(user1.id, "a.png", 2, "a.jpg-uuid")
.await .await
.unwrap(); .unwrap();
file_repository file_repository
.create_file_record("b.png", 2, "b.jpg-uuid") .create_file_record(user2.id, "b.png", 2, "b.jpg-uuid")
.await .await
.unwrap(); .unwrap();
let files = file_repository.list_files().await.unwrap(); let files = file_repository.list_files(user1.id).await.unwrap();
assert_eq!(files.len(), 2); 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;
@@ -382,14 +392,16 @@ mod tests {
.unwrap(); .unwrap();
let file_record = file_repository let file_record = file_repository
.create_file_record("a.jpg", file_size, &file_storage_key) .create_file_record(user.id, "a.jpg", file_size, &file_storage_key)
.await .await
.unwrap(); .unwrap();
let downloaded = file_repository.download_file(file_record.id).await.unwrap(); let _downloaded = file_repository.download_file(file_record.id).await.unwrap();
//TODO: add assert
// 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]
@@ -407,6 +419,8 @@ 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;
@@ -421,7 +435,7 @@ mod tests {
.unwrap(); .unwrap();
let file_record = file_repository let file_record = file_repository
.create_file_record("a.jpg", file_size, &file_storage_key) .create_file_record(user.id, "a.jpg", file_size, &file_storage_key)
.await .await
.unwrap(); .unwrap();
@@ -432,6 +446,7 @@ mod tests {
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]

View File

@@ -9,8 +9,7 @@ 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 {
@@ -23,6 +22,7 @@ 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");
@@ -44,7 +44,7 @@ async fn upload_file(
if let (Some(name), Some(key), Some(size)) = (file_name, file_storage_key, file_size) { if let (Some(name), Some(key), Some(size)) = (file_name, file_storage_key, file_size) {
let file_record = file_repository let file_record = file_repository
.create_file_record(&name, size, &key) .create_file_record(ctx.user_id(), &name, size, &key)
.await?; .await?;
return Ok(Json(file_record)); return Ok(Json(file_record));
} }
@@ -82,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))
} }

View File

@@ -5,11 +5,10 @@ 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};
pub fn routes_auth(user_repository: UserRepository) -> Router { pub fn routes_auth(user_repository: UserRepository) -> Router {
Router::new() Router::new()
.route("/login", post(login)) .route("/login", post(login))
@@ -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
@@ -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;
} }
} }