diff --git a/moneymgr_backend/Cargo.lock b/moneymgr_backend/Cargo.lock index 55c3144..d978de7 100644 --- a/moneymgr_backend/Cargo.lock +++ b/moneymgr_backend/Cargo.lock @@ -2110,6 +2110,7 @@ dependencies = [ "rust-s3", "serde", "serde_json", + "sha2", "thiserror 2.0.12", "tokio", ] diff --git a/moneymgr_backend/Cargo.toml b/moneymgr_backend/Cargo.toml index d89f8a1..05fca71 100644 --- a/moneymgr_backend/Cargo.toml +++ b/moneymgr_backend/Cargo.toml @@ -29,3 +29,4 @@ lazy-regex = "3.4.1" jwt-simple = { version = "0.12.11", default-features = false, features = ["pure-rust"] } mime_guess = "2.0.5" rust-embed = { version = "8.6.0" } +sha2 = "0.10.8" diff --git a/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/down.sql b/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/down.sql index 9937a64..c69e570 100644 --- a/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/down.sql +++ b/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/down.sql @@ -1,6 +1,6 @@ DROP TABLE IF EXISTS inbox; DROP TABLE IF EXISTS movements; DROP TABLE IF EXISTS accounts; -DROP TABLE IF EXISTS attachments; +DROP TABLE IF EXISTS files; DROP TABLE IF EXISTS tokens; DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/up.sql b/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/up.sql index ca8bd49..9184d5e 100644 --- a/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/up.sql +++ b/moneymgr_backend/migrations/2025-03-17-173101_initial_structure/up.sql @@ -9,29 +9,30 @@ CREATE TABLE users CREATE TABLE tokens ( - id SERIAL PRIMARY KEY, - name VARCHAR(150) NOT NULL, - time_create BIGINT NOT NULL, - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, - token_value VARCHAR(150) NOT NULL, - time_used BIGINT NOT NULL, - max_inactivity INTEGER NOT NULL, - ip_net VARCHAR(50), - read_only BOOLEAN NOT NULL DEFAULT true, - right_account BOOLEAN NOT NULL DEFAULT false, - right_movement BOOLEAN NOT NULL DEFAULT false, - right_inbox BOOLEAN NOT NULL DEFAULT false, - right_attachment BOOLEAN NOT NULL DEFAULT false, - right_auth BOOLEAN NOT NULL DEFAULT false + id SERIAL PRIMARY KEY, + name VARCHAR(150) NOT NULL, + time_create BIGINT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + token_value VARCHAR(150) NOT NULL, + time_used BIGINT NOT NULL, + max_inactivity INTEGER NOT NULL, + ip_net VARCHAR(50), + read_only BOOLEAN NOT NULL DEFAULT true, + right_account BOOLEAN NOT NULL DEFAULT false, + right_movement BOOLEAN NOT NULL DEFAULT false, + right_inbox BOOLEAN NOT NULL DEFAULT false, + right_file BOOLEAN NOT NULL DEFAULT false, + right_auth BOOLEAN NOT NULL DEFAULT false ); -CREATE TABLE attachments +CREATE TABLE files ( id SERIAL PRIMARY KEY, time_create BIGINT NOT NULL, mime_type VARCHAR(150) NOT NULL, sha512 VARCHAR(130) NOT NULL, file_size INTEGER NOT NULL, + file_name VARCHAR(150) NOT NULL, user_id INTEGER NOT NULL REFERENCES users ON DELETE SET NULL ); @@ -47,23 +48,23 @@ CREATE TABLE accounts CREATE TABLE movements ( - id SERIAL PRIMARY KEY, - account_id INTEGER NOT NULL REFERENCES accounts ON DELETE CASCADE, - time BIGINT NOT NULL, - label VARCHAR(200) NOT NULL, - attachment_id INT REFERENCES attachments ON DELETE SET NULL, - amount REAL NOT NULL, - checked BOOLEAN NOT NULL DEFAULT false, - time_create BIGINT NOT NULL, - time_update BIGINT NOT NULL + id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES accounts ON DELETE CASCADE, + time BIGINT NOT NULL, + label VARCHAR(200) NOT NULL, + file_id INT REFERENCES files ON DELETE SET NULL, + amount REAL NOT NULL, + checked BOOLEAN NOT NULL DEFAULT false, + time_create BIGINT NOT NULL, + time_update BIGINT NOT NULL ); CREATE TABLE inbox ( - id SERIAL PRIMARY KEY, - attachment_id INTEGER NOT NULL REFERENCES attachments ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, - account_id INTEGER REFERENCES accounts ON DELETE CASCADE, - time_create BIGINT NOT NULL, - time_update BIGINT NOT NULL + id SERIAL PRIMARY KEY, + file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + account_id INTEGER REFERENCES accounts ON DELETE CASCADE, + time_create BIGINT NOT NULL, + time_update BIGINT NOT NULL ); \ No newline at end of file diff --git a/moneymgr_backend/src/constants.rs b/moneymgr_backend/src/constants.rs index 0f2356d..0c57108 100644 --- a/moneymgr_backend/src/constants.rs +++ b/moneymgr_backend/src/constants.rs @@ -18,3 +18,6 @@ pub mod sessions { /// Authenticated ID pub const USER_ID: &str = "uid"; } + +/// Maximum uploaded file size (15MB) +pub const MAX_UPLOAD_FILE_SIZE: usize = 15 * 1024 * 1024; diff --git a/moneymgr_backend/src/controllers/files_controller.rs b/moneymgr_backend/src/controllers/files_controller.rs new file mode 100644 index 0000000..42fa637 --- /dev/null +++ b/moneymgr_backend/src/controllers/files_controller.rs @@ -0,0 +1,18 @@ +use crate::controllers::HttpResult; +use crate::extractors::auth_extractor::AuthExtractor; +use crate::extractors::file_extractor::FileExtractor; +use crate::services::files_service; +use actix_web::HttpResponse; + +/// Upload a new file +pub async fn upload(auth: AuthExtractor, file: FileExtractor) -> HttpResult { + let file = files_service::create_file_with_mimetype( + auth.user_id(), + &file.name(), + &file.mime, + &file.buff, + ) + .await?; + + Ok(HttpResponse::Ok().json(file)) +} diff --git a/moneymgr_backend/src/controllers/mod.rs b/moneymgr_backend/src/controllers/mod.rs index 90ee21e..49c3510 100644 --- a/moneymgr_backend/src/controllers/mod.rs +++ b/moneymgr_backend/src/controllers/mod.rs @@ -4,6 +4,7 @@ use std::error::Error; pub mod accounts_controller; pub mod auth_controller; +pub mod files_controller; pub mod server_controller; pub mod static_controller; pub mod tokens_controller; diff --git a/moneymgr_backend/src/controllers/tokens_controller.rs b/moneymgr_backend/src/controllers/tokens_controller.rs index 52d8ab5..21cc5f5 100644 --- a/moneymgr_backend/src/controllers/tokens_controller.rs +++ b/moneymgr_backend/src/controllers/tokens_controller.rs @@ -59,7 +59,7 @@ pub async fn create(auth: AuthExtractor, req: web::Json) -> Htt right_account: req.right_account, right_movement: req.right_movement, right_inbox: req.right_inbox, - right_attachment: req.right_attachment, + right_file: req.right_attachment, right_auth: req.right_auth, }) .await?; diff --git a/moneymgr_backend/src/extractors/auth_extractor.rs b/moneymgr_backend/src/extractors/auth_extractor.rs index f1940cf..a164d40 100644 --- a/moneymgr_backend/src/extractors/auth_extractor.rs +++ b/moneymgr_backend/src/extractors/auth_extractor.rs @@ -144,7 +144,7 @@ impl FromRequest for AuthExtractor { let authorized = (uri.starts_with("/api/account") && token.right_account) || (uri.starts_with("/api/movement") && token.right_movement) || (uri.starts_with("/api/inbox") && token.right_inbox) - || (uri.starts_with("/api/attachment") && token.right_attachment) + || (uri.starts_with("/api/file") && token.right_file) || (uri.starts_with("/api/auth/") && token.right_auth); if !authorized { diff --git a/moneymgr_backend/src/extractors/file_extractor.rs b/moneymgr_backend/src/extractors/file_extractor.rs new file mode 100644 index 0000000..58803e3 --- /dev/null +++ b/moneymgr_backend/src/extractors/file_extractor.rs @@ -0,0 +1,74 @@ +use crate::constants; +use actix_multipart::form::MultipartForm; +use actix_multipart::form::tempfile::TempFile; +use actix_web::dev::Payload; +use actix_web::{Error, FromRequest, HttpRequest}; +use mime_guess::Mime; +use std::io::Read; +use std::str::FromStr; + +#[derive(Debug, MultipartForm)] +struct FileUploadForm { + #[multipart(rename = "file")] + file: TempFile, +} + +pub struct FileExtractor { + pub buff: Vec, + pub mime: Mime, + pub name: Option, +} + +impl FileExtractor { + pub fn name(&self) -> String { + match &self.name { + None => { + let ext = self.mime.suffix().map(|s| s.as_str()).unwrap_or("bin"); + format!("file.{ext}") + } + Some(f) => f.to_string(), + } + } +} + +impl FromRequest for FileExtractor { + type Error = Error; + type Future = futures_util::future::LocalBoxFuture<'static, Result>; + + fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { + let form = MultipartForm::::from_request(req, payload); + + Box::pin(async move { + let mut form = form.await?; + + if form.file.size > constants::MAX_UPLOAD_FILE_SIZE { + return Err(actix_web::error::ErrorPayloadTooLarge( + "Uploaded file is too large!", + )); + } + + let mut buff = Vec::with_capacity(form.file.size); + form.file.file.read_to_end(&mut buff)?; + + let mime = match form + .file + .content_type + .clone() + .or_else(|| Mime::from_str(form.file.file_name.as_deref().unwrap_or("")).ok()) + { + Some(s) => s, + None => { + return Err(actix_web::error::ErrorBadRequest( + "Mimetype was not specified!!", + )); + } + }; + + Ok(Self { + mime, + buff, + name: form.file.file_name.clone(), + }) + }) + } +} diff --git a/moneymgr_backend/src/extractors/mod.rs b/moneymgr_backend/src/extractors/mod.rs index f219368..0272db3 100644 --- a/moneymgr_backend/src/extractors/mod.rs +++ b/moneymgr_backend/src/extractors/mod.rs @@ -1,3 +1,4 @@ pub mod account_extractor; pub mod auth_extractor; +pub mod file_extractor; pub mod money_session; diff --git a/moneymgr_backend/src/main.rs b/moneymgr_backend/src/main.rs index e6ff9ea..7d196c1 100644 --- a/moneymgr_backend/src/main.rs +++ b/moneymgr_backend/src/main.rs @@ -119,6 +119,8 @@ async fn main() -> std::io::Result<()> { "/api/account/{account_id}", web::delete().to(accounts_controller::delete), ) + // Files controller + .route("/api/file", web::post().to(files_controller::upload)) // Static assets .route("/", web::get().to(static_controller::root_index)) .route( diff --git a/moneymgr_backend/src/models/files.rs b/moneymgr_backend/src/models/files.rs new file mode 100644 index 0000000..c77afbb --- /dev/null +++ b/moneymgr_backend/src/models/files.rs @@ -0,0 +1,47 @@ +use crate::models::users::UserID; +use crate::schema::*; +use diesel::prelude::*; + +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct FileID(pub i32); + +#[derive(Queryable, Debug, serde::Serialize)] +pub struct File { + id: i32, + pub time_create: i64, + pub mime_type: String, + pub sha512: String, + pub file_size: i32, + pub file_name: String, + user_id: i32, +} + +impl File { + pub fn id(&self) -> FileID { + FileID(self.id) + } + pub fn file_path(&self) -> String { + format!("blob/{}/{}", self.user_id, self.sha512) + } + + pub fn user_id(&self) -> UserID { + UserID(self.id) + } +} + +#[derive(Insertable)] +#[diesel(table_name = files)] +pub struct NewFile<'a> { + pub time_create: i64, + pub mime_type: &'a str, + pub sha512: &'a str, + pub file_size: i32, + pub file_name: &'a str, + pub user_id: i32, +} + +impl NewFile<'_> { + pub fn file_path(&self) -> String { + format!("blob/{}/{}", self.user_id, self.sha512) + } +} diff --git a/moneymgr_backend/src/models/mod.rs b/moneymgr_backend/src/models/mod.rs index 626d891..f388e95 100644 --- a/moneymgr_backend/src/models/mod.rs +++ b/moneymgr_backend/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod accounts; +pub mod files; pub mod tokens; pub mod users; diff --git a/moneymgr_backend/src/models/tokens.rs b/moneymgr_backend/src/models/tokens.rs index 44a1470..9b69e9e 100644 --- a/moneymgr_backend/src/models/tokens.rs +++ b/moneymgr_backend/src/models/tokens.rs @@ -32,7 +32,7 @@ pub struct Token { pub right_account: bool, pub right_movement: bool, pub right_inbox: bool, - pub right_attachment: bool, + pub right_file: bool, pub right_auth: bool, } @@ -76,6 +76,6 @@ pub struct NewToken<'a> { pub right_account: bool, pub right_movement: bool, pub right_inbox: bool, - pub right_attachment: bool, + pub right_file: bool, pub right_auth: bool, } diff --git a/moneymgr_backend/src/schema.rs b/moneymgr_backend/src/schema.rs index b830fc1..6c5b78c 100644 --- a/moneymgr_backend/src/schema.rs +++ b/moneymgr_backend/src/schema.rs @@ -13,7 +13,7 @@ diesel::table! { } diesel::table! { - attachments (id) { + files (id) { id -> Int4, time_create -> Int8, #[max_length = 150] @@ -21,6 +21,8 @@ diesel::table! { #[max_length = 130] sha512 -> Varchar, file_size -> Int4, + #[max_length = 150] + file_name -> Varchar, user_id -> Int4, } } @@ -28,7 +30,7 @@ diesel::table! { diesel::table! { inbox (id) { id -> Int4, - attachment_id -> Int4, + file_id -> Int4, user_id -> Int4, account_id -> Nullable, time_create -> Int8, @@ -43,7 +45,7 @@ diesel::table! { time -> Int8, #[max_length = 200] label -> Varchar, - attachment_id -> Nullable, + file_id -> Nullable, amount -> Float4, checked -> Bool, time_create -> Int8, @@ -68,7 +70,7 @@ diesel::table! { right_account -> Bool, right_movement -> Bool, right_inbox -> Bool, - right_attachment -> Bool, + right_file -> Bool, right_auth -> Bool, } } @@ -86,19 +88,12 @@ diesel::table! { } diesel::joinable!(accounts -> users (user_id)); -diesel::joinable!(attachments -> users (user_id)); +diesel::joinable!(files -> users (user_id)); diesel::joinable!(inbox -> accounts (account_id)); -diesel::joinable!(inbox -> attachments (attachment_id)); +diesel::joinable!(inbox -> files (file_id)); diesel::joinable!(inbox -> users (user_id)); diesel::joinable!(movements -> accounts (account_id)); -diesel::joinable!(movements -> attachments (attachment_id)); +diesel::joinable!(movements -> files (file_id)); diesel::joinable!(tokens -> users (user_id)); -diesel::allow_tables_to_appear_in_same_query!( - accounts, - attachments, - inbox, - movements, - tokens, - users, -); +diesel::allow_tables_to_appear_in_same_query!(accounts, files, inbox, movements, tokens, users,); diff --git a/moneymgr_backend/src/services/files_service.rs b/moneymgr_backend/src/services/files_service.rs new file mode 100644 index 0000000..08db222 --- /dev/null +++ b/moneymgr_backend/src/services/files_service.rs @@ -0,0 +1,125 @@ +use crate::connections::db_connection::db; +use crate::connections::s3_connection; +use crate::models::files::{File, FileID, NewFile}; +use crate::models::users::UserID; +use crate::schema::files; +use crate::utils::crypt_utils::sha512; +use crate::utils::time_utils::time; +use diesel::prelude::*; +use mime_guess::Mime; + +#[derive(thiserror::Error, Debug)] +enum FilesServiceError { + #[error("UnknownMimeType!")] + UnknownMimeType, +} + +pub async fn create_file_with_file_name( + user_id: UserID, + file_name: &str, + bytes: &[u8], +) -> anyhow::Result { + let mime = mime_guess::from_path(file_name) + .first() + .ok_or(FilesServiceError::UnknownMimeType)?; + + create_file_with_mimetype(user_id, file_name, &mime, bytes).await +} + +pub async fn create_file_with_mimetype( + user_id: UserID, + file_name: &str, + mime_type: &Mime, + bytes: &[u8], +) -> anyhow::Result { + let sha512 = sha512(bytes); + + if let Ok(f) = get_file_with_hash(user_id, &sha512) { + return Ok(f); + } + + let file = NewFile { + time_create: time() as i64, + mime_type: mime_type.as_ref(), + sha512: &sha512, + file_size: bytes.len() as i32, + file_name, + user_id: user_id.0, + }; + + s3_connection::upload_file(&file.file_path(), bytes).await?; + + let res = diesel::insert_into(files::table) + .values(&file) + .get_result(&mut db()?)?; + + Ok(res) +} + +pub fn get_file_with_hash(user_id: UserID, sha512: &str) -> anyhow::Result { + Ok(files::table + .filter( + files::dsl::sha512 + .eq(sha512) + .and(files::dsl::user_id.eq(user_id.0)), + ) + .first(&mut db()?)?) +} + +pub fn get_file_with_id(id: FileID) -> anyhow::Result { + Ok(files::table + .filter(files::dsl::id.eq(id.0)) + .first(&mut db()?)?) +} + +pub async fn get_file_content_by_id(id: FileID) -> anyhow::Result> { + let file = get_file_with_id(id)?; + get_file_content(&file).await +} + +pub async fn get_file_content(file: &File) -> anyhow::Result> { + s3_connection::get_file(&file.file_path()).await +} + +/// Delete the file if it is not referenced anymore in the database. Returns true +/// if the file was actually deleted, false otherwise +pub async fn delete_file_if_unused(id: FileID) -> anyhow::Result { + let file = get_file_with_id(id)?; + + let res = diesel::delete(files::dsl::files.filter(files::dsl::id.eq(file.id().0))) + .execute(&mut db()?); + + match res { + Ok(_) => { + s3_connection::delete_file_if_exists(&file.file_path()).await?; + log::info!("File {:?} was deleted", file.id()); + + Ok(true) + } + Err(e) => { + log::info!( + "File {:?} could not be deleted, it must be used somewhere: {e}", + file.id() + ); + Ok(false) + } + } +} + +/// Get the entire list of file +pub async fn get_entire_list() -> anyhow::Result> { + Ok(files::table.get_results(&mut db()?)?) +} + +/// Remove unused files +pub async fn run_garbage_collector() -> anyhow::Result { + let mut count_deleted = 0; + + for file in get_entire_list().await? { + if delete_file_if_unused(file.id()).await? { + count_deleted += 1; + } + } + + Ok(count_deleted) +} diff --git a/moneymgr_backend/src/services/mod.rs b/moneymgr_backend/src/services/mod.rs index 2539ab1..ec1934c 100644 --- a/moneymgr_backend/src/services/mod.rs +++ b/moneymgr_backend/src/services/mod.rs @@ -1,3 +1,4 @@ pub mod accounts_service; +pub mod files_service; pub mod tokens_service; pub mod users_service; diff --git a/moneymgr_backend/src/services/tokens_service.rs b/moneymgr_backend/src/services/tokens_service.rs index 33a9c4c..d0d702f 100644 --- a/moneymgr_backend/src/services/tokens_service.rs +++ b/moneymgr_backend/src/services/tokens_service.rs @@ -17,7 +17,7 @@ pub struct NewTokenInfo { pub right_account: bool, pub right_movement: bool, pub right_inbox: bool, - pub right_attachment: bool, + pub right_file: bool, pub right_auth: bool, } @@ -38,7 +38,7 @@ pub async fn create(new_token: NewTokenInfo) -> anyhow::Result { right_account: new_token.right_account, right_movement: new_token.right_movement, right_inbox: new_token.right_inbox, - right_attachment: new_token.right_attachment, + right_file: new_token.right_file, }; let res = diesel::insert_into(tokens::table) diff --git a/moneymgr_backend/src/utils/crypt_utils.rs b/moneymgr_backend/src/utils/crypt_utils.rs new file mode 100644 index 0000000..2f8173c --- /dev/null +++ b/moneymgr_backend/src/utils/crypt_utils.rs @@ -0,0 +1,9 @@ +use sha2::{Digest, Sha512}; + +/// Compute hash of a slice of bytes (sha512) +pub fn sha512(bytes: &[u8]) -> String { + let mut hasher = Sha512::new(); + hasher.update(bytes); + let h = hasher.finalize(); + format!("{:x}", h) +} diff --git a/moneymgr_backend/src/utils/mod.rs b/moneymgr_backend/src/utils/mod.rs index 223d6b8..ff5baec 100644 --- a/moneymgr_backend/src/utils/mod.rs +++ b/moneymgr_backend/src/utils/mod.rs @@ -1,2 +1,3 @@ +pub mod crypt_utils; pub mod rand_utils; pub mod time_utils; diff --git a/moneymgr_web/src/api/TokensApi.ts b/moneymgr_web/src/api/TokensApi.ts index bd1b0cb..13d2af3 100644 --- a/moneymgr_web/src/api/TokensApi.ts +++ b/moneymgr_web/src/api/TokensApi.ts @@ -12,7 +12,7 @@ export interface Token { right_account: boolean; right_movement: boolean; right_inbox: boolean; - right_attachment: boolean; + right_file: boolean; right_auth: boolean; } @@ -28,7 +28,7 @@ export interface NewToken { right_account: boolean; right_movement: boolean; right_inbox: boolean; - right_attachment: boolean; + right_file: boolean; right_auth: boolean; } diff --git a/moneymgr_web/src/dialogs/CreateTokenDialog.tsx b/moneymgr_web/src/dialogs/CreateTokenDialog.tsx index f7f12d2..0aa9790 100644 --- a/moneymgr_web/src/dialogs/CreateTokenDialog.tsx +++ b/moneymgr_web/src/dialogs/CreateTokenDialog.tsx @@ -32,7 +32,7 @@ export function CreateTokenDialog(p: { max_inactivity: 3600 * 24 * 90, read_only: false, right_account: false, - right_attachment: false, + right_file: false, right_auth: false, right_inbox: false, right_movement: false, @@ -74,7 +74,7 @@ export function CreateTokenDialog(p: { read_only: false, right_account: false, right_movement: false, - right_attachment: true, + right_file: true, right_auth: true, right_inbox: true, }); @@ -181,12 +181,12 @@ export function CreateTokenDialog(p: {
{ setNewToken({ ...newToken, - right_attachment: v, + right_file: v, }); }} /> diff --git a/moneymgr_web/src/routes/TokensRoute.tsx b/moneymgr_web/src/routes/TokensRoute.tsx index e05ec90..f5f6482 100644 --- a/moneymgr_web/src/routes/TokensRoute.tsx +++ b/moneymgr_web/src/routes/TokensRoute.tsx @@ -180,8 +180,8 @@ function TokensRouteInner(p: { type: "boolean", }, { - field: "right_attachment", - headerName: "Attachment", + field: "right_file", + headerName: "File", flex: 2, type: "boolean", },