Can upload files

This commit is contained in:
Pierre HUBERT 2025-04-09 21:12:47 +02:00
parent 84e1c57dc9
commit 61a4ea62c6
24 changed files with 342 additions and 61 deletions

View File

@ -2110,6 +2110,7 @@ dependencies = [
"rust-s3", "rust-s3",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
] ]

View File

@ -29,3 +29,4 @@ lazy-regex = "3.4.1"
jwt-simple = { version = "0.12.11", default-features = false, features = ["pure-rust"] } jwt-simple = { version = "0.12.11", default-features = false, features = ["pure-rust"] }
mime_guess = "2.0.5" mime_guess = "2.0.5"
rust-embed = { version = "8.6.0" } rust-embed = { version = "8.6.0" }
sha2 = "0.10.8"

View File

@ -1,6 +1,6 @@
DROP TABLE IF EXISTS inbox; DROP TABLE IF EXISTS inbox;
DROP TABLE IF EXISTS movements; DROP TABLE IF EXISTS movements;
DROP TABLE IF EXISTS accounts; DROP TABLE IF EXISTS accounts;
DROP TABLE IF EXISTS attachments; DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS tokens; DROP TABLE IF EXISTS tokens;
DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS users;

View File

@ -21,17 +21,18 @@ CREATE TABLE tokens
right_account BOOLEAN NOT NULL DEFAULT false, right_account BOOLEAN NOT NULL DEFAULT false,
right_movement BOOLEAN NOT NULL DEFAULT false, right_movement BOOLEAN NOT NULL DEFAULT false,
right_inbox BOOLEAN NOT NULL DEFAULT false, right_inbox BOOLEAN NOT NULL DEFAULT false,
right_attachment BOOLEAN NOT NULL DEFAULT false, right_file BOOLEAN NOT NULL DEFAULT false,
right_auth BOOLEAN NOT NULL DEFAULT false right_auth BOOLEAN NOT NULL DEFAULT false
); );
CREATE TABLE attachments CREATE TABLE files
( (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
time_create BIGINT NOT NULL, time_create BIGINT NOT NULL,
mime_type VARCHAR(150) NOT NULL, mime_type VARCHAR(150) NOT NULL,
sha512 VARCHAR(130) NOT NULL, sha512 VARCHAR(130) NOT NULL,
file_size INTEGER NOT NULL, file_size INTEGER NOT NULL,
file_name VARCHAR(150) NOT NULL,
user_id INTEGER NOT NULL REFERENCES users ON DELETE SET NULL user_id INTEGER NOT NULL REFERENCES users ON DELETE SET NULL
); );
@ -51,7 +52,7 @@ CREATE TABLE movements
account_id INTEGER NOT NULL REFERENCES accounts ON DELETE CASCADE, account_id INTEGER NOT NULL REFERENCES accounts ON DELETE CASCADE,
time BIGINT NOT NULL, time BIGINT NOT NULL,
label VARCHAR(200) NOT NULL, label VARCHAR(200) NOT NULL,
attachment_id INT REFERENCES attachments ON DELETE SET NULL, file_id INT REFERENCES files ON DELETE SET NULL,
amount REAL NOT NULL, amount REAL NOT NULL,
checked BOOLEAN NOT NULL DEFAULT false, checked BOOLEAN NOT NULL DEFAULT false,
time_create BIGINT NOT NULL, time_create BIGINT NOT NULL,
@ -61,7 +62,7 @@ CREATE TABLE movements
CREATE TABLE inbox CREATE TABLE inbox
( (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
attachment_id INTEGER NOT NULL REFERENCES attachments ON DELETE CASCADE, file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
account_id INTEGER REFERENCES accounts ON DELETE CASCADE, account_id INTEGER REFERENCES accounts ON DELETE CASCADE,
time_create BIGINT NOT NULL, time_create BIGINT NOT NULL,

View File

@ -18,3 +18,6 @@ pub mod sessions {
/// Authenticated ID /// Authenticated ID
pub const USER_ID: &str = "uid"; pub const USER_ID: &str = "uid";
} }
/// Maximum uploaded file size (15MB)
pub const MAX_UPLOAD_FILE_SIZE: usize = 15 * 1024 * 1024;

View File

@ -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))
}

View File

@ -4,6 +4,7 @@ use std::error::Error;
pub mod accounts_controller; pub mod accounts_controller;
pub mod auth_controller; pub mod auth_controller;
pub mod files_controller;
pub mod server_controller; pub mod server_controller;
pub mod static_controller; pub mod static_controller;
pub mod tokens_controller; pub mod tokens_controller;

View File

@ -59,7 +59,7 @@ pub async fn create(auth: AuthExtractor, req: web::Json<CreateTokenBody>) -> Htt
right_account: req.right_account, right_account: req.right_account,
right_movement: req.right_movement, right_movement: req.right_movement,
right_inbox: req.right_inbox, right_inbox: req.right_inbox,
right_attachment: req.right_attachment, right_file: req.right_attachment,
right_auth: req.right_auth, right_auth: req.right_auth,
}) })
.await?; .await?;

View File

@ -144,7 +144,7 @@ impl FromRequest for AuthExtractor {
let authorized = (uri.starts_with("/api/account") && token.right_account) let authorized = (uri.starts_with("/api/account") && token.right_account)
|| (uri.starts_with("/api/movement") && token.right_movement) || (uri.starts_with("/api/movement") && token.right_movement)
|| (uri.starts_with("/api/inbox") && token.right_inbox) || (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); || (uri.starts_with("/api/auth/") && token.right_auth);
if !authorized { if !authorized {

View File

@ -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<u8>,
pub mime: Mime,
pub name: Option<String>,
}
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<Self, Self::Error>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let form = MultipartForm::<FileUploadForm>::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(),
})
})
}
}

View File

@ -1,3 +1,4 @@
pub mod account_extractor; pub mod account_extractor;
pub mod auth_extractor; pub mod auth_extractor;
pub mod file_extractor;
pub mod money_session; pub mod money_session;

View File

@ -119,6 +119,8 @@ async fn main() -> std::io::Result<()> {
"/api/account/{account_id}", "/api/account/{account_id}",
web::delete().to(accounts_controller::delete), web::delete().to(accounts_controller::delete),
) )
// Files controller
.route("/api/file", web::post().to(files_controller::upload))
// Static assets // Static assets
.route("/", web::get().to(static_controller::root_index)) .route("/", web::get().to(static_controller::root_index))
.route( .route(

View File

@ -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)
}
}

View File

@ -1,3 +1,4 @@
pub mod accounts; pub mod accounts;
pub mod files;
pub mod tokens; pub mod tokens;
pub mod users; pub mod users;

View File

@ -32,7 +32,7 @@ pub struct Token {
pub right_account: bool, pub right_account: bool,
pub right_movement: bool, pub right_movement: bool,
pub right_inbox: bool, pub right_inbox: bool,
pub right_attachment: bool, pub right_file: bool,
pub right_auth: bool, pub right_auth: bool,
} }
@ -76,6 +76,6 @@ pub struct NewToken<'a> {
pub right_account: bool, pub right_account: bool,
pub right_movement: bool, pub right_movement: bool,
pub right_inbox: bool, pub right_inbox: bool,
pub right_attachment: bool, pub right_file: bool,
pub right_auth: bool, pub right_auth: bool,
} }

View File

@ -13,7 +13,7 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
attachments (id) { files (id) {
id -> Int4, id -> Int4,
time_create -> Int8, time_create -> Int8,
#[max_length = 150] #[max_length = 150]
@ -21,6 +21,8 @@ diesel::table! {
#[max_length = 130] #[max_length = 130]
sha512 -> Varchar, sha512 -> Varchar,
file_size -> Int4, file_size -> Int4,
#[max_length = 150]
file_name -> Varchar,
user_id -> Int4, user_id -> Int4,
} }
} }
@ -28,7 +30,7 @@ diesel::table! {
diesel::table! { diesel::table! {
inbox (id) { inbox (id) {
id -> Int4, id -> Int4,
attachment_id -> Int4, file_id -> Int4,
user_id -> Int4, user_id -> Int4,
account_id -> Nullable<Int4>, account_id -> Nullable<Int4>,
time_create -> Int8, time_create -> Int8,
@ -43,7 +45,7 @@ diesel::table! {
time -> Int8, time -> Int8,
#[max_length = 200] #[max_length = 200]
label -> Varchar, label -> Varchar,
attachment_id -> Nullable<Int4>, file_id -> Nullable<Int4>,
amount -> Float4, amount -> Float4,
checked -> Bool, checked -> Bool,
time_create -> Int8, time_create -> Int8,
@ -68,7 +70,7 @@ diesel::table! {
right_account -> Bool, right_account -> Bool,
right_movement -> Bool, right_movement -> Bool,
right_inbox -> Bool, right_inbox -> Bool,
right_attachment -> Bool, right_file -> Bool,
right_auth -> Bool, right_auth -> Bool,
} }
} }
@ -86,19 +88,12 @@ diesel::table! {
} }
diesel::joinable!(accounts -> users (user_id)); 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 -> accounts (account_id));
diesel::joinable!(inbox -> attachments (attachment_id)); diesel::joinable!(inbox -> files (file_id));
diesel::joinable!(inbox -> users (user_id)); diesel::joinable!(inbox -> users (user_id));
diesel::joinable!(movements -> accounts (account_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::joinable!(tokens -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(accounts, files, inbox, movements, tokens, users,);
accounts,
attachments,
inbox,
movements,
tokens,
users,
);

View File

@ -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<File> {
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<File> {
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<File> {
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<File> {
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<Vec<u8>> {
let file = get_file_with_id(id)?;
get_file_content(&file).await
}
pub async fn get_file_content(file: &File) -> anyhow::Result<Vec<u8>> {
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<bool> {
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<Vec<File>> {
Ok(files::table.get_results(&mut db()?)?)
}
/// Remove unused files
pub async fn run_garbage_collector() -> anyhow::Result<usize> {
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)
}

View File

@ -1,3 +1,4 @@
pub mod accounts_service; pub mod accounts_service;
pub mod files_service;
pub mod tokens_service; pub mod tokens_service;
pub mod users_service; pub mod users_service;

View File

@ -17,7 +17,7 @@ pub struct NewTokenInfo {
pub right_account: bool, pub right_account: bool,
pub right_movement: bool, pub right_movement: bool,
pub right_inbox: bool, pub right_inbox: bool,
pub right_attachment: bool, pub right_file: bool,
pub right_auth: bool, pub right_auth: bool,
} }
@ -38,7 +38,7 @@ pub async fn create(new_token: NewTokenInfo) -> anyhow::Result<Token> {
right_account: new_token.right_account, right_account: new_token.right_account,
right_movement: new_token.right_movement, right_movement: new_token.right_movement,
right_inbox: new_token.right_inbox, 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) let res = diesel::insert_into(tokens::table)

View File

@ -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)
}

View File

@ -1,2 +1,3 @@
pub mod crypt_utils;
pub mod rand_utils; pub mod rand_utils;
pub mod time_utils; pub mod time_utils;

View File

@ -12,7 +12,7 @@ export interface Token {
right_account: boolean; right_account: boolean;
right_movement: boolean; right_movement: boolean;
right_inbox: boolean; right_inbox: boolean;
right_attachment: boolean; right_file: boolean;
right_auth: boolean; right_auth: boolean;
} }
@ -28,7 +28,7 @@ export interface NewToken {
right_account: boolean; right_account: boolean;
right_movement: boolean; right_movement: boolean;
right_inbox: boolean; right_inbox: boolean;
right_attachment: boolean; right_file: boolean;
right_auth: boolean; right_auth: boolean;
} }

View File

@ -32,7 +32,7 @@ export function CreateTokenDialog(p: {
max_inactivity: 3600 * 24 * 90, max_inactivity: 3600 * 24 * 90,
read_only: false, read_only: false,
right_account: false, right_account: false,
right_attachment: false, right_file: false,
right_auth: false, right_auth: false,
right_inbox: false, right_inbox: false,
right_movement: false, right_movement: false,
@ -74,7 +74,7 @@ export function CreateTokenDialog(p: {
read_only: false, read_only: false,
right_account: false, right_account: false,
right_movement: false, right_movement: false,
right_attachment: true, right_file: true,
right_auth: true, right_auth: true,
right_inbox: true, right_inbox: true,
}); });
@ -181,12 +181,12 @@ export function CreateTokenDialog(p: {
<br /> <br />
<CheckboxInput <CheckboxInput
editable editable
label="Right: attachment routes" label="Right: file routes"
checked={newToken.right_attachment} checked={newToken.right_file}
onValueChange={(v) => { onValueChange={(v) => {
setNewToken({ setNewToken({
...newToken, ...newToken,
right_attachment: v, right_file: v,
}); });
}} }}
/> />

View File

@ -180,8 +180,8 @@ function TokensRouteInner(p: {
type: "boolean", type: "boolean",
}, },
{ {
field: "right_attachment", field: "right_file",
headerName: "Attachment", headerName: "File",
flex: 2, flex: 2,
type: "boolean", type: "boolean",
}, },