Compare commits

..

No commits in common. "d80846ffb28c004be6b1896cf1b0ae0ca4684999" and "eef5cd8e0218c866b6ebcba38bcef2133f6d550a" have entirely different histories.

69 changed files with 2031 additions and 1351 deletions

503
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +1,8 @@
[package]
name = "homedisk"
authors = ["MedzikUser <medzik@duck.com>"]
homepage = "https://github.com/HomeDisk/cloud"
repository = "https://github.com/HomeDisk/cloud"
license = "GPL-3.0-or-later"
version = "0.0.0-dev"
edition = "2021"
[dependencies]
# Types
serde = { version = "1.0", features = ["derive"] }
# Errors
anyhow = "1.0"
thiserror = "1.0"
# Logger
tracing = { version = "0.1", features = ["max_level_debug", "release_max_level_info", "log"] }
tracing-subscriber = "0.3"
# Backtrace in panic hook
backtrace = "0.3"
# Tokio runtime
tokio = { version = "1.21", features = ["rt-multi-thread", "macros"] }
# Database
futures-util = "0.3"
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "sqlite"] }
uuid = { version = "1.1", features = ["v4"] }
# Cryptographic
crypto-utils = "0.4"
hex = "0.4"
# Config
toml = "0.5"
# HTTP server
axum = { version = "0.6.0-rc.2", features = ["http2", "multipart"] }
tower-http = { version = "0.3", features = ["full"] }
hyper = { version = "0.14", features = ["full"] }
byte-unit = "4.0.14"
[workspace]
members = [
"core",
"database",
"server",
"types",
]
resolver = "2"

22
core/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "homedisk"
authors = ["MedzikUser <medzik@duck.com>"]
homepage = "https://github.com/HomeDisk/cloud"
repository = "https://github.com/HomeDisk/cloud"
license = "GPL-3.0-or-later"
version = "0.0.0"
edition = "2021"
[[bin]]
name = "homedisk"
path = "./src/main.rs"
[dependencies]
anyhow = "1.0.62"
better-panic = "0.3.0"
tracing-subscriber = "0.3.15"
tracing = { version = "0.1.36", features = ["max_level_debug", "release_max_level_info"] }
tokio = { version = "1.20.1", features = ["rt-multi-thread", "macros"] }
homedisk-database = { path = "../database" }
homedisk-server = { path = "../server" }
homedisk-types = { path = "../types", features = ["config"] }

36
core/src/lib.rs Normal file
View File

@ -0,0 +1,36 @@
//! # HomeDisk cloud server
//!
//! [github]: https://img.shields.io/badge/github-8da0cb?style=for-the-badge&labelColor=555555&logo=github
//! [docs-rs]: https://img.shields.io/badge/docs.rs-66c2a5?style=for-the-badge&labelColor=555555&logo=docs.rs
//! [total-lines]: https://img.shields.io/tokei/lines/github/MedzikUser/HomeDisk?style=for-the-badge&logo=github&color=fede00
//! [code-size]: https://img.shields.io/github/languages/code-size/MedzikUser/HomeDisk?style=for-the-badge&color=c8df52&logo=github
//! [ci]: https://img.shields.io/github/workflow/status/MedzikUser/HomeDisk/Rust/main?style=for-the-badge
//!
//! [home-screenshot]: https://i.imgur.com/x4Glw7w.png
//! [login-screenshot]: https://i.imgur.com/KpwY4nb.png
//!
//! [![github]](https://github.com/MedzikUser/HomeDisk)
//! [![docs-rs]](https://homedisk-doc.vercel.app)
//! [![total-lines]](https://github.com/MedzikUser/HomeDisk)
//! [![code-size]](https://github.com/MedzikUser/HomeDisk)
//! [![ci]](https://github.com/MedzikUser/HomeDisk/actions/workflows/rust.yml)
//!
//! ![home-screenshot]
//! ![login-screenshot]
//!
//! ## 👨‍💻 Building
//!
//! First clone the repository: `git clone https://github.com/MedzikUser/HomeDisk.git`
//!
//! ### Requirements
//! - [Rust](https://rust-lang.org)
//!
//! To build run the command: `cargo build --release`
//!
//! The compiled binary can be found in `./target/release/homedisk`
//!
//! ## Configure
//!
//! Go to [config](homedisk_types::config) module
#![doc(html_root_url = "https://homedisk-doc.medzik.xyz")]

17
core/src/logger.rs Normal file
View File

@ -0,0 +1,17 @@
use tracing::level_filters::LevelFilter;
// Max Logger Level on debug build
#[cfg(debug_assertions)]
const MAX_LEVEL: LevelFilter = LevelFilter::DEBUG;
// Max Logger Level on release build
#[cfg(not(debug_assertions))]
const MAX_LEVEL: LevelFilter = LevelFilter::INFO;
pub fn init() {
// initialize better_panic
better_panic::install();
// initialize tracing
tracing_subscriber::fmt().with_max_level(MAX_LEVEL).init();
}

65
core/src/main.rs Normal file
View File

@ -0,0 +1,65 @@
use std::{fs::File, path::Path};
use homedisk_database::Database;
use homedisk_server::serve_http;
use homedisk_types::config::Config;
use tracing::{info, warn};
mod logger;
pub const DATABASE_FILE: &str = "homedisk.db";
#[tokio::main]
async fn main() {
logger::init();
let config = Config::parse().expect("Failed to parse configuration file");
// open database connection
let db =
// if database file doesn't exists create it
if !Path::new(DATABASE_FILE).exists() {
warn!("Database file doesn't exists.");
info!("Creating database file...");
// create an empty database file
File::create(DATABASE_FILE).expect("Failed to create a database file");
// open database file
let db = Database::open(DATABASE_FILE)
.await
.expect("Failed to open database file");
// create tables in the database
db.create_tables()
.await
.expect("Failed to create tables in the database");
db
}
// if database file exists
else {
Database::open(DATABASE_FILE)
.await
.expect("Failed to open database file")
};
// change the type from Vec<String> to Vec<HeaderValue> so that the http server can correctly detect CORS hosts
let origins = config
.http
.cors
.iter()
.map(|e| e.parse().expect("parse CORS hosts"))
.collect();
// format host ip and port
let host = format!(
"{host}:{port}",
host = config.http.host,
port = config.http.port
);
serve_http(host, origins, db, config)
.await
.expect("start http server");
}

14
database/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "homedisk-database"
version = "0.0.0"
edition = "2021"
[dependencies]
tracing = "0.1.36"
futures-util = "0.3.23"
sqlx = { version = "0.6.1", features = ["runtime-tokio-rustls", "sqlite"] }
uuid = { version = "1.1.2", features = ["v5"] }
homedisk-types = { path = "../types", features = ["database"] }
[dev-dependencies]
tokio = { version = "1.20.1", features = ["rt-multi-thread", "macros"] }

9
database/src/lib.rs Normal file
View File

@ -0,0 +1,9 @@
#![doc(html_root_url = "https://homedisk-doc.medzik.xyz")]
mod sqlite;
pub use homedisk_types::{
database::User,
errors::{DatabaseError as Error, DatabaseResult as Result},
};
pub use sqlite::*;

286
database/src/sqlite.rs Normal file
View File

@ -0,0 +1,286 @@
use std::str::FromStr;
use futures_util::TryStreamExt;
use sqlx::{
sqlite::{SqliteConnectOptions, SqliteQueryResult},
ConnectOptions, Executor, Row, SqlitePool,
};
use tracing::{debug, log::LevelFilter};
use super::{Error, Result, User};
/// SQL Database
#[derive(Debug, Clone)]
pub struct Database {
/// SQLite Connection Pool
pub conn: SqlitePool,
}
impl Database {
/// Open a SQLite database
/// ```no_run
/// # async fn foo() -> homedisk_database::Result<()> {
/// use homedisk_database::Database;
///
/// // open database in memory
/// Database::open("sqlite::memory:").await?;
///
/// // open database from file
/// Database::open("path/to/database.db").await?;
///
/// # Ok(()) }
/// ```
pub async fn open(path: &str) -> Result<Self> {
debug!("Opening SQLite database");
// sqlite connection options
let mut options = SqliteConnectOptions::from_str(path).map_err(Error::OpenDatabase)?;
// set log level to Debug
options.log_statements(LevelFilter::Debug);
// create a database pool
let conn = SqlitePool::connect_with(options)
.await
.map_err(Error::ConnectDatabase)?;
// return `Database`
Ok(Self { conn })
}
/// Create all required tabled for HomeDisk
/// ```
/// # async fn foo() -> homedisk_database::Result<()> {
/// # let db = homedisk_database::Database::open("sqlite::memory:").await?;
/// db.create_tables().await?;
///
/// # Ok(()) }
/// ```
pub async fn create_tables(&self) -> Result<SqliteQueryResult> {
let query = sqlx::query(include_str!("../../tables.sql"));
self.conn.execute(query).await.map_err(Error::Execute)
}
/// Create a new User
/// ```
/// # async fn foo() -> homedisk_database::Result<()> {
/// # let db = homedisk_database::Database::open("sqlite::memory:").await?;
/// # db.create_tables().await?;
/// use homedisk_database::User;
///
/// // create `User` type
/// let user = User::new("username", "password");
///
/// // create a user in database
/// db.create_user(&user).await?;
///
/// # Ok(()) }
/// ```
pub async fn create_user(&self, user: &User) -> Result<SqliteQueryResult> {
debug!("Creating user - {}", user.username);
// insert user to a database
let query = sqlx::query("INSERT INTO user (id, username, password) VALUES (?, ?, ?)")
.bind(&user.id)
.bind(&user.username)
.bind(&user.password);
// execute query and return output
self.conn.execute(query).await.map_err(Error::Execute)
}
/// Search for a user
/// ```
/// # async fn foo() -> homedisk_database::Result<()> {
/// # let db = homedisk_database::Database::open("sqlite::memory:").await?;
/// # db.create_tables().await?;
/// use homedisk_database::User;
///
/// // create `User` type
/// let user = User::new("username", "password");
///
/// # db.create_user(&user).await?;
/// db.find_user(&user).await?;
///
/// # Ok(()) }
/// ```
pub async fn find_user(&self, user: &User) -> Result<User> {
debug!("Searching for a user - {}", user.username);
// create query request to database
let query =
sqlx::query_as::<_, User>("SELECT * FROM user WHERE username = ? AND password = ?")
.bind(&user.username)
.bind(&user.password);
// fetch query
let mut stream = self.conn.fetch(query);
// get rows from query
let row = stream
.try_next()
.await
.map_err(Error::Execute)?
.ok_or(Error::UserNotFound)?;
// get `id` row
let id = row.try_get("id").map_err(Error::GetRow)?;
// get `username` row
let username = row.try_get("username").map_err(Error::GetRow)?;
// get `password` row
let password = row.try_get("password").map_err(Error::GetRow)?;
// return `User`
Ok(User {
id,
username,
password,
})
}
/// Search for a user by UUID
/// ```
/// # async fn foo() -> homedisk_database::Result<()> {
/// # let db = homedisk_database::Database::open("sqlite::memory:").await?;
/// # db.create_tables().await?;
/// use homedisk_database::User;
///
/// // create `User` type
/// let user = User::new("username", "password");
///
/// # db.create_user(&user).await?;
/// db.find_user_by_id(&user.id).await?;
///
/// # Ok(()) }
/// ```
pub async fn find_user_by_id(&self, id: &str) -> Result<User> {
debug!("Searching for a user by UUID - {}", id);
// create query request to database
let query = sqlx::query_as::<_, User>("SELECT * FROM user WHERE id = ?").bind(id);
// fetch query
let mut stream = self.conn.fetch(query);
// get rows from query
let row = stream
.try_next()
.await
.map_err(Error::Execute)?
.ok_or(Error::UserNotFound)?;
// get `id` row
let id = row.try_get("id").map_err(Error::GetRow)?;
// get `username` row
let username = row.try_get("username").map_err(Error::GetRow)?;
// get `password` row
let password = row.try_get("password").map_err(Error::GetRow)?;
// return `User`
Ok(User {
id,
username,
password,
})
}
}
#[cfg(test)]
mod tests {
use crate::{Database, User};
const USERNAME: &str = "medzik";
const PASSWORD: &str = "SuperSecretPassword123!";
/// Utils to open database in tests
async fn open_db() -> Database {
Database::open("sqlite::memory:").await.expect("open db")
}
/// Utils to create a new user in tests
async fn new_user(db: &Database) {
// create tables
db.create_tables().await.expect("create tables");
// create new user
let user = User::new(USERNAME, PASSWORD);
db.create_user(&user).await.expect("create user");
}
/// Test a create user
#[tokio::test]
async fn create_user() {
let db = open_db().await;
new_user(&db).await;
}
// Test a search for a user
#[tokio::test]
async fn find_user() {
let db = open_db().await;
new_user(&db).await;
let user = User::new(USERNAME, PASSWORD);
let user = db.find_user(&user).await.unwrap();
assert_eq!(user.username, USERNAME)
}
// Test a search for a user by id
#[tokio::test]
async fn find_user_by_id() {
let db = open_db().await;
new_user(&db).await;
let user = User::new(USERNAME, PASSWORD);
let user = db.find_user_by_id(&user.id).await.unwrap();
assert_eq!(user.username, USERNAME)
}
/// Test a search for a user with an invalid password to see if the user is returned (it shouldn't be)
#[tokio::test]
async fn find_user_wrong_password() {
let db = open_db().await;
new_user(&db).await;
let user = User::new(USERNAME, "wrong password 123!");
let err = db.find_user(&user).await.unwrap_err();
assert_eq!(err.to_string(), "user not found")
}
/// Test a search for a user who doesn't exist
#[tokio::test]
async fn find_user_wrong_username() {
let db = open_db().await;
new_user(&db).await;
let user = User::new("not_exists_user", PASSWORD);
let err = db.find_user(&user).await.unwrap_err();
assert_eq!(err.to_string(), "user not found")
}
/// Test a search for a user by UUID who doesn't exist
#[tokio::test]
async fn find_user_wrong_id() {
let db = open_db().await;
new_user(&db).await;
let other_user = User::new("not_exists_user", "my secret passphrase");
let err = db.find_user_by_id(&other_user.id).await.unwrap_err();
assert_eq!(err.to_string(), "user not found")
}
}

View File

@ -22,4 +22,4 @@
"automerge": true
}
]
}
}

19
server/Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "homedisk-server"
version = "0.0.0"
edition = "2021"
[dependencies]
axum = { version = "0.5.15", features = ["multipart"] }
axum-auth = "0.3.0"
base64 = "0.13.0"
byte-unit = "4.0.14"
futures = "0.3.23"
hyper = { version = "0.14.20", features = ["full"] }
log = "0.4.17"
crypto-utils = { version = "0.4.0", features = ["jwt"] }
serde = { version = "1.0.144", features = ["derive"] }
thiserror = "1.0.32"
tower-http = { version = "0.3.4", features = ["full"] }
homedisk-database = { path = "../database" }
homedisk-types = { path = "../types", features = ["axum"] }

46
server/src/auth/login.rs Normal file
View File

@ -0,0 +1,46 @@
use axum::{extract::rejection::JsonRejection, Extension, Json};
use homedisk_database::{Database, Error, User};
use homedisk_types::{
auth::login::{Request, Response},
config::Config,
errors::{AuthError, ServerError},
};
use crate::middleware::{create_token, validate_json};
pub async fn handle(
Extension(db): Extension<Database>,
Extension(config): Extension<Config>,
request: Result<Json<Request>, JsonRejection>,
) -> Result<Json<Response>, ServerError> {
// validate json request
let request = validate_json(request)?;
// create `User` type
let user = User::new(&request.username, &request.password);
// search for a user in database
let response = match db.find_user(&user).await {
Ok(user) => {
// create user token
let token = create_token(&user, config.jwt.secret.as_bytes(), config.jwt.expires)?;
Response {
access_token: token,
}
},
// error while searching for a user
Err(err) => {
return match err {
// user not found
Error::UserNotFound => Err(ServerError::AuthError(AuthError::UserNotFound)),
// other error
_ => Err(ServerError::AuthError(AuthError::Other(err.to_string()))),
};
},
};
// send response
Ok(Json(response))
}

12
server/src/auth/mod.rs Normal file
View File

@ -0,0 +1,12 @@
mod login;
mod register;
mod whoami;
pub fn app() -> axum::Router {
use axum::routing::{get, post};
axum::Router::new()
.route("/login", post(login::handle))
.route("/register", post(register::handle))
.route("/whoami", get(whoami::handle))
}

View File

@ -0,0 +1,67 @@
use std::fs;
use axum::{extract::rejection::JsonRejection, Extension, Json};
use homedisk_database::{Database, User};
use homedisk_types::{
auth::login::{Request, Response},
config::Config,
errors::{AuthError, FsError, ServerError},
};
use crate::middleware::{create_token, validate_json};
pub async fn handle(
Extension(db): Extension<Database>,
Extension(config): Extension<Config>,
request: Result<Json<Request>, JsonRejection>,
) -> Result<Json<Response>, ServerError> {
// validate json request
let request = validate_json(request)?;
// username must contain at least 4 characters
if request.username.len() < 4 {
return Err(ServerError::AuthError(AuthError::UsernameTooShort));
}
// username must be less than 25 characters
if request.username.len() > 25 {
return Err(ServerError::AuthError(AuthError::UsernameTooLong));
}
// password must contain at least 8 characters
if request.password.len() < 8 {
return Err(ServerError::AuthError(AuthError::PasswordTooShort));
}
// create `User` type and hash password
let user = User::new(&request.username, &request.password);
// create user in the database
let response = match db.create_user(&user).await {
Ok(_result) => {
let token = create_token(&user, config.jwt.secret.as_bytes(), config.jwt.expires)?;
Response {
access_token: token,
}
},
// error while searching for a user
Err(err) => {
// user already exists
if err.to_string().contains("UNIQUE constraint failed") {
return Err(ServerError::AuthError(AuthError::UserAlreadyExists));
}
// other error
return Err(ServerError::AuthError(AuthError::Other(err.to_string())));
},
};
// create directory for the user files
fs::create_dir_all(&format!("{}/{}", config.storage.path, user.username,))
.map_err(|e| ServerError::FsError(FsError::CreateDirectory(e.to_string())))?;
// send response
Ok(Json(response))
}

37
server/src/auth/whoami.rs Normal file
View File

@ -0,0 +1,37 @@
use axum::{Extension, Json};
use axum_auth::AuthBearer;
use homedisk_database::{Database, Error};
use homedisk_types::{
auth::whoami::Response,
config::Config,
errors::{AuthError, ServerError},
};
use crate::middleware::validate_jwt;
pub async fn handle(
db: Extension<Database>,
config: Extension<Config>,
AuthBearer(token): AuthBearer,
) -> Result<Json<Response>, ServerError> {
// validate user token
let token = validate_jwt(config.jwt.secret.as_bytes(), &token)?;
// search for a user in database
let response = match db.find_user_by_id(&token.claims.sub).await {
Ok(user) => Response {
username: user.username,
},
// error while searching for a user
Err(err) => match err {
// user not found
Error::UserNotFound => return Err(ServerError::AuthError(AuthError::UserNotFound)),
// other error
_ => return Err(ServerError::AuthError(AuthError::Other(err.to_string()))),
},
};
// send response
Ok(Json(response))
}

34
server/src/error.rs Normal file
View File

@ -0,0 +1,34 @@
// HTTP Error
#[derive(Debug, thiserror::Error)]
pub enum Error {
// axum::Error
#[error("axum error - {0}")]
Axum(axum::Error),
// hyper::Error
#[error("hyper error - {0}")]
Hyper(hyper::Error),
// std::net::AddrParseError
#[error("std::net::AddrParseError - {0}")]
AddrParseError(std::net::AddrParseError),
}
/// Custom Result
pub type Result<T> = std::result::Result<T, Error>;
impl From<axum::Error> for Error {
fn from(err: axum::Error) -> Self {
Error::Axum(err)
}
}
impl From<hyper::Error> for Error {
fn from(err: hyper::Error) -> Self {
Error::Hyper(err)
}
}
impl From<std::net::AddrParseError> for Error {
fn from(err: std::net::AddrParseError) -> Self {
Error::AddrParseError(err)
}
}

View File

@ -0,0 +1,45 @@
use std::fs;
use axum::{extract::rejection::JsonRejection, Extension, Json};
use axum_auth::AuthBearer;
use homedisk_database::Database;
use homedisk_types::{
config::Config,
errors::{FsError, ServerError},
fs::create_dir::Request,
};
use crate::middleware::{find_user, validate_json, validate_jwt, validate_path};
pub async fn handle(
Extension(db): Extension<Database>,
Extension(config): Extension<Config>,
AuthBearer(token): AuthBearer,
request: Result<Json<Request>, JsonRejection>,
) -> Result<(), ServerError> {
// validate json request
let Json(request) = validate_json(request)?;
// validate user token
let token = validate_jwt(config.jwt.secret.as_bytes(), &token)?;
// validate the `path` can be used
validate_path(&request.path)?;
// search for a user by UUID from a token
let user = find_user(&db, &token.claims.sub).await?;
// directory where the file will be placed
let path = format!(
"{user_dir}/{req_dir}",
user_dir = user.user_dir(&config.storage.path),
req_dir = request.path
);
// create directories
fs::create_dir_all(path)
.map_err(|err| ServerError::FsError(FsError::CreateDirectory(err.to_string())))?;
// send an empty response
Ok(())
}

55
server/src/fs/delete.rs Normal file
View File

@ -0,0 +1,55 @@
use std::{fs, path::Path};
use axum::{extract::Query, Extension};
use axum_auth::AuthBearer;
use homedisk_database::Database;
use homedisk_types::{
config::Config,
errors::{FsError, ServerError},
fs::delete::Request,
};
use crate::middleware::{find_user, validate_jwt, validate_path};
pub async fn handle(
Extension(db): Extension<Database>,
Extension(config): Extension<Config>,
AuthBearer(token): AuthBearer,
query: Query<Request>,
) -> Result<(), ServerError> {
// validate user token
let token = validate_jwt(config.jwt.secret.as_bytes(), &token)?;
// validate the `path` can be used
validate_path(&query.path)?;
// search for a user by UUID from a token
let user = find_user(&db, &token.claims.sub).await?;
// path to the file
let path = format!(
"{user_dir}/{request_path}",
user_dir = user.user_dir(&config.storage.path),
request_path = query.path
);
let path = Path::new(&path);
// if file does not exist return error
if !path.exists() {
return Err(ServerError::FsError(FsError::FileDoesNotExist));
}
// delete file
if path.is_file() {
fs::remove_file(&path)
.map_err(|err| ServerError::FsError(FsError::DeleteFile(err.to_string())))?;
}
// delete directory
else if path.is_dir() {
fs::remove_dir(&path)
.map_err(|err| ServerError::FsError(FsError::DeleteDirectory(err.to_string())))?;
}
// send an empty response
Ok(())
}

42
server/src/fs/download.rs Normal file
View File

@ -0,0 +1,42 @@
use std::fs;
use axum::{extract::Query, Extension};
use axum_auth::AuthBearer;
use homedisk_database::Database;
use homedisk_types::{
config::Config,
errors::{FsError, ServerError},
fs::upload::Pagination,
};
use crate::middleware::{find_user, validate_jwt, validate_path};
pub async fn handle(
Extension(db): Extension<Database>,
Extension(config): Extension<Config>,
AuthBearer(token): AuthBearer,
query: Query<Pagination>,
) -> Result<Vec<u8>, ServerError> {
// validate user token
let token = validate_jwt(config.jwt.secret.as_bytes(), &token)?;
// validate the `path` can be used
validate_path(&query.path)?;
// search for a user by UUID from a token
let user = find_user(&db, &token.claims.sub).await?;
// directory where the file will be placed
let path = format!(
"{user_dir}/{req_dir}",
user_dir = user.user_dir(&config.storage.path),
req_dir = query.path
);
// read file content
let content =
fs::read(path).map_err(|err| ServerError::FsError(FsError::ReadFile(err.to_string())))?;
// send file content in response
Ok(content)
}

112
server/src/fs/list.rs Normal file
View File

@ -0,0 +1,112 @@
use std::{fs, io, path::PathBuf, time::SystemTime};
use axum::{extract::rejection::JsonRejection, Extension, Json};
use axum_auth::AuthBearer;
use byte_unit::Byte;
use homedisk_database::Database;
use homedisk_types::{
config::Config,
errors::{FsError, ServerError},
fs::list::{DirInfo, FileInfo, Request, Response},
};
use crate::middleware::{find_user, validate_json, validate_jwt, validate_path};
/// Get directory size on disk (size of all files in directory).
fn dir_size(path: impl Into<PathBuf>) -> io::Result<u64> {
fn dir_size(mut dir: fs::ReadDir) -> io::Result<u64> {
dir.try_fold(0, |acc, file| {
let file = file?;
let size = match file.metadata()? {
data if data.is_dir() => dir_size(fs::read_dir(file.path())?)?,
data => data.len(),
};
Ok(acc + size)
})
}
dir_size(fs::read_dir(path.into())?)
}
pub async fn handle(
Extension(db): Extension<Database>,
Extension(config): Extension<Config>,
AuthBearer(token): AuthBearer,
request: Result<Json<Request>, JsonRejection>,
) -> Result<Json<Response>, ServerError> {
// validate json request
let Json(request) = validate_json::<Request>(request)?;
// validate user token
let token = validate_jwt(config.jwt.secret.as_bytes(), &token)?;
// validate the `path` can be used
validate_path(&request.path)?;
// search for a user by UUID from a token
let user = find_user(&db, &token.claims.sub).await?;
// directory where the file will be placed
let path = format!(
"{user_dir}/{req_dir}",
user_dir = user.user_dir(&config.storage.path),
req_dir = request.path
);
// get paths from dir
let paths = fs::read_dir(&path)
.map_err(|err| ServerError::FsError(FsError::ReadDirectory(err.to_string())))?;
let mut files = vec![];
let mut dirs = vec![];
for f in paths {
// handle Error
let f = f.map_err(|err| ServerError::FsError(FsError::Other(err.to_string())))?;
// get path metadata
let metadata = f
.metadata()
.map_err(|err| ServerError::FsError(FsError::Other(err.to_string())))?;
// get name of the path
let name = f.path().display().to_string().replace(&path, "");
// if path is directory
if metadata.is_dir() {
let size = Byte::from_bytes(
dir_size(f.path().display().to_string())
.map_err(|err| ServerError::FsError(FsError::Other(err.to_string())))?
.into(),
)
.get_appropriate_unit(true)
.to_string();
dirs.push(DirInfo { name, size })
}
// if path is file
else {
// get file size in bytes
let size = Byte::from_bytes(metadata.len().into())
.get_appropriate_unit(true)
.to_string();
// get modification time in unix time format
let modified = metadata
.modified()
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
files.push(FileInfo {
name,
size,
modified,
})
}
}
// send response
Ok(Json(Response { files, dirs }))
}

16
server/src/fs/mod.rs Normal file
View File

@ -0,0 +1,16 @@
mod create_dir;
mod delete;
mod download;
mod list;
mod upload;
pub fn app() -> axum::Router {
use axum::routing::{delete, get, post};
axum::Router::new()
.route("/list", post(list::handle))
.route("/upload", post(upload::handle))
.route("/delete", delete(delete::handle))
.route("/download", get(download::handle))
.route("/createdir", post(create_dir::handle))
}

79
server/src/fs/upload.rs Normal file
View File

@ -0,0 +1,79 @@
use std::{fs, io::Write, path::Path};
use axum::{
extract::{Multipart, Query},
Extension,
};
use axum_auth::AuthBearer;
use futures::TryStreamExt;
use homedisk_database::Database;
use homedisk_types::{
config::Config,
errors::{FsError, ServerError},
fs::upload::Pagination,
};
use crate::middleware::{find_user, validate_jwt, validate_path};
pub async fn handle(
Extension(db): Extension<Database>,
Extension(config): Extension<Config>,
AuthBearer(token): AuthBearer,
mut multipart: Multipart,
query: Query<Pagination>,
) -> Result<(), ServerError> {
// validate user token
let token = validate_jwt(config.jwt.secret.as_bytes(), &token)?;
// validate the `path` can be used
validate_path(&query.path)?;
// search for a user by UUID from a token
let user = find_user(&db, &token.claims.sub).await?;
// path to the file
let file_path = format!(
"{user_dir}/{request_path}",
user_dir = user.user_dir(&config.storage.path),
request_path = query.path
);
let file_path = Path::new(&file_path);
// check if the file currently exists to avoid overwriting it
if file_path.exists() {
return Err(ServerError::FsError(FsError::FileAlreadyExists));
}
// create a directory where the file will be placed
// e.g. path ==> `/secret/files/images/screenshot.png`
// directories up to `{storage dir}/{username}/secret/files/images/` will be created
if let Some(prefix) = file_path.parent() {
fs::create_dir_all(&prefix)
.map_err(|err| ServerError::FsError(FsError::CreateFile(err.to_string())))?
}
// get multipart field
let field = multipart
.next_field()
.await
.map_err(|_| ServerError::FsError(FsError::MultipartError))?
.ok_or(ServerError::FsError(FsError::MultipartError))?;
// create file
let file = std::fs::File::create(&file_path)
.map_err(|err| ServerError::FsError(FsError::CreateFile(err.to_string())))?;
// write file (chunk by chunk)
field
.try_fold((file, 0u64), |(mut file, written_len), bytes| async move {
file.write_all(bytes.as_ref())
.expect("failed to write chunk to file");
Ok((file, written_len + bytes.len() as u64))
})
.await
.map_err(|err| ServerError::FsError(FsError::WriteFile(err.to_string())))?;
// send an empty response
Ok(())
}

44
server/src/lib.rs Normal file
View File

@ -0,0 +1,44 @@
#![doc(html_root_url = "https://homedisk-doc.medzik.xyz")]
mod auth;
mod error;
mod fs;
mod middleware;
use axum::{http::HeaderValue, routing::get, Extension, Router, Server};
use homedisk_database::Database;
use homedisk_types::config::Config;
use log::{debug, info};
use tower_http::cors::{AllowOrigin, CorsLayer};
/// Handle `/health-check` requests
async fn health_check() -> &'static str {
"I'm alive!"
}
/// Start HTTP server
pub async fn serve_http(
host: String,
origins: Vec<HeaderValue>,
db: Database,
config: Config,
) -> error::Result<()> {
debug!("Starting http server");
info!("Website available at: http://{host}");
// create http Router
let app = Router::new()
.route("/health-check", get(health_check))
.nest("/api/auth", auth::app())
.nest("/api/fs", fs::app())
.layer(CorsLayer::new().allow_origin(AllowOrigin::list(origins)))
.layer(Extension(db))
.layer(Extension(config));
// bind the provided address and serve Router
Server::bind(&host.parse()?)
.serve(app.into_make_service())
.await?;
Ok(())
}

View File

@ -0,0 +1,46 @@
use crypto_utils::jsonwebtoken::{Token, TokenData};
use homedisk_types::errors::{AuthError, ServerError};
/// Validate user token
pub fn validate_jwt(secret: &[u8], token: &str) -> Result<TokenData, ServerError> {
match Token::decode(secret, token.to_string()) {
// if success return claims
Ok(claims) => Ok(claims),
// invalid token
Err(_) => Err(ServerError::AuthError(AuthError::InvalidToken)),
}
}
#[cfg(test)]
mod tests {
use homedisk_database::User;
use super::validate_jwt;
use crate::middleware::create_token;
const USERNAME: &str = "username";
const PASSWORD: &str = "password";
const SECRET: &[u8] = b"secret";
const INVALID_SECRET: &[u8] = b"invalid secret";
/// Test a token validation
#[test]
fn validate_token() {
let user = User::new(USERNAME, PASSWORD);
let token = create_token(&user, SECRET, 1).unwrap();
validate_jwt(SECRET, &token).unwrap();
}
/// Test a token validation (invalid secret)
#[test]
fn validate_token_invalid_secret() {
let user = User::new(USERNAME, PASSWORD);
let token = create_token(&user, SECRET, 1).unwrap();
validate_jwt(INVALID_SECRET, &token).unwrap_err();
}
}

View File

@ -0,0 +1,49 @@
use crypto_utils::jsonwebtoken::{Claims, Token};
use homedisk_database::{Database, User};
use homedisk_types::errors::{AuthError, ServerError};
/// Create user token
pub fn create_token(user: &User, secret: &[u8], expires: i64) -> Result<String, ServerError> {
let token = Token::new(secret, Claims::new(&user.id, expires))
.map_err(|_| ServerError::AuthError(AuthError::TokenGenerate))?;
Ok(token.encoded)
}
/// Search for a user
pub async fn find_user(db: &Database, user_id: &str) -> Result<User, ServerError> {
match db.find_user_by_id(user_id).await {
// if success return user
Ok(user) => Ok(user),
// errors
Err(err) => match err {
// user not found
homedisk_database::Error::UserNotFound => {
Err(ServerError::AuthError(AuthError::UserNotFound))
},
// other error
_ => Err(ServerError::AuthError(AuthError::Other(err.to_string()))),
},
}
}
#[cfg(test)]
mod tests {
use homedisk_database::User;
use super::create_token;
const SECRET: &[u8] = b"secret";
const USERNAME: &str = "username";
const PASSWORD: &str = "password";
/// Test a token creation
#[test]
fn test_create_token() {
let secret = SECRET;
let user = User::new(USERNAME, PASSWORD);
create_token(&user, secret, 1).unwrap();
}
}

View File

@ -0,0 +1,9 @@
mod auth;
mod jwt;
mod validate_json;
mod validate_path;
pub use auth::*;
pub use jwt::*;
pub use validate_json::*;
pub use validate_path::*;

View File

@ -0,0 +1,20 @@
use axum::{extract::rejection::JsonRejection, Json};
use homedisk_types::errors::ServerError;
/// Validate json request
pub fn validate_json<T>(payload: Result<Json<T>, JsonRejection>) -> Result<Json<T>, ServerError> {
match payload {
// if success return payload
Ok(payload) => Ok(payload),
// mission json in Content-Type Header
Err(JsonRejection::MissingJsonContentType(_)) => Err(ServerError::InvalidContentType),
// failed to deserialize json
Err(JsonRejection::JsonDataError(_)) => Err(ServerError::JsonDataError),
// syntax error in json
Err(JsonRejection::JsonSyntaxError(_)) => Err(ServerError::JsonSyntaxError),
// failed to extract the request body
Err(JsonRejection::BytesRejection(_)) => Err(ServerError::BytesRejection),
// other error
Err(err) => Err(ServerError::Other(err.to_string())),
}
}

View File

@ -0,0 +1,40 @@
use homedisk_types::errors::{FsError, ServerError};
/// Validate path param provided in the request
pub fn validate_path(path: &str) -> Result<(), ServerError> {
// `path` can't contain `..`
// to prevent attack attempts because by using a `..` you can access the previous folder
if path.contains("..") {
return Err(ServerError::FsError(FsError::ReadDirectory(
"the `path` can't contain `..`".to_string(),
)));
}
// `path` can't contain `~`
// to prevent attack attempts because `~` can get up a directory on `$HOME`
if path.contains('~') {
return Err(ServerError::FsError(FsError::ReadDirectory(
"the `path` can't not contain `~`".to_string(),
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_path() {
// Successfully
assert!(validate_path("Directory/path/to/test.png").is_ok());
assert!(validate_path("/test.png").is_ok()); // `/` doesn't point to the system root
assert!(validate_path("./test.png").is_ok());
// Errors
assert!(validate_path("../../test.png").is_err());
assert!(validate_path("../test.png").is_err());
assert!(validate_path("~/test.png").is_err());
}
}

View File

@ -1,8 +0,0 @@
//! [SQLite database functions](sqlite::Database).
pub mod error;
mod sqlite;
mod user;
pub use sqlite::*;
pub use user::*;

View File

@ -1,174 +0,0 @@
use std::str::FromStr;
use futures_util::TryStreamExt;
use sqlx::{
sqlite::{SqliteConnectOptions, SqliteQueryResult, SqliteRow},
ConnectOptions, Executor, Row, SqlitePool,
};
use tracing::{debug, info, log::LevelFilter};
use super::{
error::{Error, Result},
User,
};
/// SQLite database
#[derive(Clone)]
pub struct Database {
/// Sqlite connection pool
pub pool: SqlitePool,
}
// TODO: Check UUID
impl Database {
/// Open a SQLite database
pub async fn open(path: &str) -> Result<Self> {
debug!("Opening SQLite database: {}", path);
// sqlite connection options
let mut options = SqliteConnectOptions::from_str(path).map_err(Error::OpenDatabase)?;
// set log level to Debug
options.log_statements(LevelFilter::Debug);
// create a database pool
let pool = SqlitePool::connect_with(options)
.await
.map_err(Error::ConnectDatabase)?;
info!("Connected to database!");
Ok(Self { pool })
}
/// Create all required tables for HomeDisk.
pub async fn create_tables(&self) -> Result<SqliteQueryResult> {
self.pool
.execute(include_str!("../../tables.sql"))
.await
.map_err(Error::CreateTables)
}
/// Create new user in the database.
pub async fn create_user(&self, user: &User) -> Result<SqliteQueryResult> {
debug!("Creating user - {}", user.username);
// build sql query
let query = sqlx::query("INSERT INTO user (id, username, password) VALUES (?, ?, ?)")
.bind(&user.id)
.bind(&user.username)
.bind(&user.password);
self.pool.execute(query).await.map_err(Error::Execute)
}
/// Search for a user.
pub async fn find_user(&self, user: &User) -> Result<User> {
debug!("Searching for a user - {}", user.username);
// create query request to database
let query = sqlx::query("SELECT * FROM user WHERE username = ? AND password = ?")
.bind(&user.username)
.bind(&user.password);
// fetch query
let mut stream = self.pool.fetch(query);
// get rows from query
let row = stream
.try_next()
.await
.map_err(Error::Execute)?
.ok_or(Error::UserNotFound)?;
Self::find(row)
}
/// Search for a user by UUID.
pub async fn find_user_by_id(&self, id: &str) -> Result<User> {
debug!("Searching for a user by UUID - {}", id);
// create query request to database
let query = sqlx::query("SELECT * FROM user WHERE id = ?").bind(id);
// fetch query
let mut stream = self.pool.fetch(query);
// get rows from query
let row = stream
.try_next()
.await
.map_err(Error::Execute)?
.ok_or(Error::UserNotFound)?;
Self::find(row)
}
fn find(row: SqliteRow) -> Result<User> {
// get `id` row
let id = row.try_get("id").map_err(Error::GetRow)?;
// get `username` row
let username = row.try_get("username").map_err(Error::GetRow)?;
// get `password` row
let password = row.try_get("password").map_err(Error::GetRow)?;
Ok(User {
id,
username,
password,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const USERNAME: &str = "medzik";
const PASSWORD: &str = "SuperSecretPassword123!";
async fn open_db() -> Database {
Database::open("sqlite::memory:")
.await
.expect("Failed to open database in memory")
}
async fn new_user() -> Database {
let db = open_db().await;
// create tables
db.create_tables().await.expect("create tables");
// create new user
let user = User::new(USERNAME, PASSWORD, true);
db.create_user(&user).await.expect("create user");
db
}
#[tokio::test]
async fn test_create_user() {
new_user().await;
}
#[tokio::test]
async fn test_find_user() {
let db = new_user().await;
let user = db.find_user(&User::new(USERNAME, PASSWORD, false)).await.unwrap();
assert_eq!(user.username, USERNAME)
}
#[tokio::test]
async fn test_find_user_wrong_password() {
let db = new_user().await;
let err = db
.find_user(&User::new(USERNAME, "wrong password 123!", false))
.await
.unwrap_err();
assert_eq!(err.to_string(), "user not found")
}
}

View File

@ -1,93 +0,0 @@
use crypto_utils::sha::{Algorithm, CryptographicHash};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// SQL user entry
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub username: String,
pub password: String,
}
impl User {
/// The function create a unique UUID and compute SHA-512 hash from salted user password
/// and returns the [User] type.
///
/// **Note**: This function **does not** create a user in database!
pub fn new(username: &str, password: &str, gen_uuid: bool) -> Self {
// change username to lowercase
let username = username.to_lowercase();
// salting the password
let password = format!("{username}${password}");
// hash the password using SHA-512 algorithm and encode it into String.
let password = hex::encode(CryptographicHash::hash(
Algorithm::SHA512,
password.as_bytes(),
));
// generate UUID
let id = if gen_uuid {
Uuid::new_v4().to_string()
} else {
"none".to_string()
};
Self {
id,
username,
password,
}
}
/// The function returns the directory where the user files is located.
pub fn user_dir(&self, storage: &str) -> String {
format!("{storage}/{username}", username = self.username)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Check if the username is in lowercase.
#[test]
fn test_username_is_lowercase() {
// example user data
let username = "MeDZiK";
let password = "password";
// expected username
let username_expected = "medzik";
let user = User::new(username, password, false);
// username validation
assert_eq!(user.username, username_expected)
}
/// Check if the password is hashed.
#[test]
fn test_password_hashed() {
// example user data
let username = "username";
let password = "password";
let user = User::new(username, password, false);
assert_ne!(user.password, password)
}
#[test]
fn test_user_dir() {
let storage_dir: &str = "/storage";
let user = User::new("medzik", "whatever", false);
let user_dir = user.user_dir(storage_dir);
assert_eq!(user_dir, "/storage/medzik");
}
}

View File

@ -1,77 +0,0 @@
//! Initialize logger.
use std::{panic, thread};
use tracing::{error, level_filters::LevelFilter};
#[cfg(debug_assertions)]
const MAX_LEVEL: LevelFilter = LevelFilter::DEBUG;
#[cfg(not(debug_assertions))]
const MAX_LEVEL: LevelFilter = LevelFilter::INFO;
/// Initialize logger (tracing and panic hook).
pub fn init() {
tracing_subscriber::fmt().with_max_level(MAX_LEVEL).init();
// catch panic and log them using tracing instead of default output to StdErr
panic::set_hook(Box::new(|info| {
let thread = thread::current();
let thread = thread.name().unwrap_or("unknown");
let msg = match info.payload().downcast_ref::<&'static str>() {
Some(s) => *s,
None => match info.payload().downcast_ref::<String>() {
Some(s) => &**s,
None => "Box<Any>",
},
};
let backtrace = backtrace::Backtrace::new();
match info.location() {
Some(location) => {
// without backtrace
if msg.starts_with("notrace - ") {
error!(
target: "panic", "thread '{}' panicked at '{}': {}:{}",
thread,
msg.replace("notrace - ", ""),
location.file(),
location.line()
);
}
// with backtrace
else {
error!(
target: "panic", "thread '{}' panicked at '{}': {}:{}\n{:?}",
thread,
msg,
location.file(),
location.line(),
backtrace
);
}
},
None => {
// without backtrace
if msg.starts_with("notrace - ") {
error!(
target: "panic", "thread '{}' panicked at '{}'",
thread,
msg.replace("notrace - ", ""),
);
}
// with backtrace
else {
error!(
target: "panic", "thread '{}' panicked at '{}'\n{:?}",
thread,
msg,
backtrace
);
}
},
}
}));
}

View File

@ -1,90 +0,0 @@
//! HomeDisk cloud server
//!
//! [github]: https://img.shields.io/badge/github-8da0cb?style=for-the-badge&labelColor=555555&logo=github
//! [docs-rs]: https://img.shields.io/badge/docs.rs-66c2a5?style=for-the-badge&labelColor=555555&logo=docs.rs
//! [total-lines]: https://img.shields.io/tokei/lines/github/MedzikUser/HomeDisk?style=for-the-badge&logo=github&color=fede00
//! [code-size]: https://img.shields.io/github/languages/code-size/MedzikUser/HomeDisk?style=for-the-badge&color=c8df52&logo=github
//! [ci]: https://img.shields.io/github/workflow/status/MedzikUser/HomeDisk/Rust/main?style=for-the-badge
//!
//! [home-screenshot]: https://raw.githubusercontent.com/HomeDisk/.github/main/screenshots/home.png
//!
//! [![github]](https://github.com/MedzikUser/HomeDisk)
//! [![docs-rs]](https://homedisk-doc.vercel.app)
//! [![total-lines]](https://github.com/MedzikUser/HomeDisk)
//! [![code-size]](https://github.com/MedzikUser/HomeDisk)
//! [![ci]](https://github.com/MedzikUser/HomeDisk/actions/workflows/rust.yml)
//!
//! ![home-screenshot]
//!
//! ## 👨‍💻 Building
//!
//! First clone the repository: `git clone https://github.com/MedzikUser/HomeDisk.git`
//!
//! ### Requirements
//! - [Rust](https://rust-lang.org)
//!
//! To build run the command: `cargo build --release`
//!
//! The compiled binary can be found in `./target/release/homedisk`
//!
//! ## Configure
//!
//! Go to [config] module
use std::{fs::File, path::Path};
use config::Config;
use tracing::{info, warn};
use crate::database::Database;
#[path = "./types/config.rs"]
mod config;
mod database;
mod logger;
mod server;
/// Default SQLite file path for the database.
pub const DATABASE_FILE: &str = "homedisk.db";
/// Default configuration file.
pub const CONFIG_FILE: &str = "config.toml";
#[tokio::main]
async fn main() {
logger::init();
let config = Config::parse(CONFIG_FILE).expect("notrace - Failed to parse configuration file");
// open database connection
let db =
// if database file doesn't exists create it
if !Path::new(DATABASE_FILE).exists() {
warn!("Database file doesn't exists.");
info!("Creating database file...");
// create an empty database file
File::create(DATABASE_FILE).expect("notrace - Failed to create a database file");
// open database file
let db = Database::open(DATABASE_FILE)
.await
.expect("notrace - Failed to open database file");
// create tables in the database
db.create_tables()
.await
.expect("notrace - Failed to create tables in the database");
db
}
// if database file exists
else {
Database::open(DATABASE_FILE)
.await
.expect("notrace - Failed to open database file")
};
server::start_server(config, db)
.await
.expect("notrace - HTTP Server error");
}

View File

@ -1,50 +0,0 @@
use axum::{Extension, Json};
use crypto_utils::jsonwebtoken::{Claims, Token};
use serde::{Deserialize, Serialize};
use crate::{
config::Config,
database::{error::Error as DatabaseError, Database, User},
server::error::*,
};
pub async fn login(
Extension(db): Extension<Database>,
Extension(config): Extension<Config>,
request: Json<Request>,
) -> Result<Json<Response>> {
let user = User::new(&request.username, &request.password, false);
let response = match db.find_user(&user).await {
Ok(user) => {
let token = Token::new(
config.jwt.secret.as_bytes(),
Claims::new(&user.id, config.jwt.expires),
)
.map_err(|_| Error::GenerateToken)?;
Response {
access_token: token.encoded,
}
},
Err(err) => {
return match err {
DatabaseError::UserNotFound => Err(Error::UserNotFound),
_ => Err(Error::Database),
};
},
};
Ok(Json(response))
}
#[derive(Debug, Deserialize)]
pub struct Request {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct Response {
pub access_token: String,
}

View File

@ -1,12 +0,0 @@
mod login;
mod register;
mod whoami;
use axum::routing::*;
pub fn app() -> Router {
Router::new()
.route("/login", post(login::login))
.route("/register", post(register::register))
.route("/whoami", get(whoami::whoami))
}

View File

@ -1,50 +0,0 @@
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use crate::{
database::{Database, User},
server::error::*,
};
pub async fn register(
Extension(db): Extension<Database>,
request: Json<Request>,
) -> Result<Json<Response>> {
// username must contain at least 4 characters
if request.username.len() < 4 {
return Err(Error::UsernameTooShort);
}
// username must be less than 25 characters
if request.username.len() > 25 {
return Err(Error::UsernameTooLong);
}
// password must contain at least 8 characters
if request.password.len() < 8 {
return Err(Error::PasswordTooShort);
}
let user = User::new(&request.username, &request.password, true);
if let Err(err) = db.create_user(&user).await {
if err.to_string().contains("UNIQUE constraint failed") {
return Err(Error::UserAlreadyExists);
}
return Err(Error::Database);
}
Ok(Json(Response { success: true }))
}
#[derive(Debug, Deserialize)]
pub struct Request {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct Response {
pub success: bool,
}

View File

@ -1,15 +0,0 @@
use axum::Json;
use serde::Serialize;
use crate::server::{error::*, utils::token::Token};
pub async fn whoami(Token(user): Token) -> Result<Json<Response>> {
Ok(Json(Response {
user: user.username,
}))
}
#[derive(Debug, Serialize)]
pub struct Response {
pub user: String,
}

View File

@ -1,38 +0,0 @@
use std::{fs, path::Path};
use axum::{Extension, Json};
use serde::Serialize;
use crate::{
config::Config,
server::{
error::*,
utils::{
path::{validate_path, PathQuery},
token::Token,
},
},
};
pub async fn create_dir(
Extension(config): Extension<Config>,
path: PathQuery,
Token(user): Token,
) -> Result<Json<Response>> {
let path = validate_path(path)?;
let path = format!("{}/{}", user.user_dir(&config.storage.path), path);
if Path::new(&path).exists() {
return Err(Error::AlreadyExists);
}
fs::create_dir_all(path).map_err(|_| Error::CreateDirectory)?;
Ok(Json(Response { success: true }))
}
#[derive(Debug, Serialize)]
pub struct Response {
pub success: bool,
}

View File

@ -1,43 +0,0 @@
use std::{fs, path::Path};
use axum::{Extension, Json};
use serde::Serialize;
use crate::{
config::Config,
server::{
error::*,
utils::{
path::{validate_path, PathQuery},
token::Token,
},
},
};
pub async fn delete(
Extension(config): Extension<Config>,
path: PathQuery,
Token(user): Token,
) -> Result<Json<Response>> {
let path = validate_path(path)?;
let path = format!("{}/{}", user.user_dir(&config.storage.path), path);
let path = Path::new(&path);
if !path.exists() {
return Err(Error::NotFound);
}
if path.is_dir() {
fs::remove_dir(path).map_err(|_| Error::DeleteDirectory)?;
} else {
fs::remove_file(path).map_err(|_| Error::DeleteFile)?;
}
Ok(Json(Response { success: true }))
}
#[derive(Debug, Serialize)]
pub struct Response {
pub success: bool,
}

View File

@ -1,86 +0,0 @@
use std::{fs, time::SystemTime};
use axum::{Extension, Json};
use byte_unit::Byte;
use serde::Serialize;
use crate::{
config::Config,
server::{
error::*,
utils::{
path::{validate_path, PathQuery},
token::Token,
},
},
};
pub async fn list(
Extension(config): Extension<Config>,
path: PathQuery,
Token(user): Token,
) -> Result<Json<Response>> {
let path = validate_path(path)?;
let mut response = Response::default();
let path = format!("{}/{}", user.user_dir(&config.storage.path), path);
let paths = fs::read_dir(&path).map_err(|_| Error::FailedReadDirectory)?;
for dir_entry in paths {
let dir_entry = dir_entry.unwrap();
let metadata = dir_entry.metadata().unwrap();
let name = dir_entry
.path()
.display()
.to_string()
.replace(&format!("{path}/"), "")
.replace(&path, "");
if metadata.is_dir() {
// TODO: add size and modification date
response.dirs.push(Entry::new(name, 0.to_string(), 0))
} else {
let size = Byte::from_bytes(metadata.len().into())
.get_appropriate_unit(true)
.to_string();
let modified = metadata
.modified()
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
response.files.push(Entry::new(name, size, modified));
}
}
Ok(Json(response))
}
#[derive(Debug, Default, Serialize)]
pub struct Response {
pub dirs: Vec<Entry>,
pub files: Vec<Entry>,
}
#[derive(Debug, Serialize)]
pub struct Entry {
pub name: String,
pub size: String,
pub modified: u64,
}
impl Entry {
pub fn new(name: String, size: String, modified: u64) -> Self {
Self {
name,
size,
modified,
}
}
}

View File

@ -1,14 +0,0 @@
mod create_dir;
mod delete;
mod list;
mod upload;
use axum::routing::*;
pub fn app() -> Router {
Router::new()
.route("/list", get(list::list))
.route("/createDir", get(create_dir::create_dir))
.route("/delete", delete(delete::delete))
.route("/upload", post(upload::upload))
}

View File

@ -1,64 +0,0 @@
use std::{fs, io::Write, path::Path};
use axum::{extract::Multipart, Extension, Json};
use futures_util::TryStreamExt;
use serde::Serialize;
use crate::{
config::Config,
server::{
error::*,
utils::{
path::{validate_path, PathQuery},
token::Token,
},
},
};
pub async fn upload(
Extension(config): Extension<Config>,
path: PathQuery,
Token(user): Token,
mut multipart: Multipart,
) -> Result<Json<Response>> {
let path = validate_path(path)?;
let path = format!("{}/{}", user.user_dir(&config.storage.path), path);
let path = Path::new(&path);
if path.exists() {
return Err(Error::AlreadyExists);
}
// create a directory where the file will be placed
// e.g. path ==> `/secret/files/images/screenshot.png`
// directories up to `{storage dir}/{username}/secret/files/images/` will be created
if let Some(prefix) = path.parent() {
fs::create_dir_all(prefix).map_err(|_| Error::CreateDirectory)?
}
let field = multipart
.next_field()
.await
.map_err(|_| Error::Multipart)?
.ok_or(Error::Multipart)?;
let file = fs::File::create(path).map_err(|_| Error::CreateFile)?;
field
.try_fold((file, 0), |(mut file, written_len), bytes| async move {
file.write_all(bytes.as_ref())
.expect("failed to write chunk to file");
Ok((file, written_len + bytes.len() as u64))
})
.await
.map_err(|_| Error::WriteFile)?;
Ok(Json(Response { success: true }))
}
#[derive(Debug, Serialize)]
pub struct Response {
pub success: bool,
}

View File

@ -1,15 +0,0 @@
mod auth;
mod fs;
use axum::routing::*;
pub async fn health() -> &'static str {
"I am working!"
}
pub fn app() -> Router {
Router::new()
.nest("/auth", auth::app())
.nest("/fs", fs::app())
.route("/health", get(health))
}

View File

@ -1,77 +0,0 @@
use serde::Serialize;
use thiserror::Error;
use tracing::error;
#[derive(Debug, Clone, Error)]
pub enum Error {
// auth error
#[error("User not found")]
UserNotFound,
#[error("User already exists")]
UserAlreadyExists,
#[error("Failed to generate access token")]
GenerateToken,
#[error("An error occurred in database request")]
Database,
#[error("Username is too short")]
UsernameTooShort,
#[error("Username is too short")]
UsernameTooLong,
#[error("Password is too short")]
PasswordTooShort,
#[error("Invalid token")]
InvalidToken,
#[error("`{0}` header is missing")]
MissingHeader(&'static str),
#[error("`Authorization` header must be a bearer token")]
MissingBearer,
// fs error
#[error("Invalid `path` query parameter")]
InvalidPath,
#[error("Failed to read files from directory")]
FailedReadDirectory,
#[error("Failed to create directory")]
CreateDirectory,
#[error("Failed to create file")]
CreateFile,
#[error("File or directory already exists")]
AlreadyExists,
#[error("File or directory does not exists")]
NotFound,
#[error("Failed to delete directory (directory is not empty?)")]
DeleteDirectory,
#[error("Failed to delete file")]
DeleteFile,
#[error("Multipart error")]
Multipart,
#[error("Write to file failed")]
WriteFile,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "kebab-case")]
enum ResponseError {
Error(String),
}
impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
use axum::http::StatusCode;
#[cfg(debug_assertions)]
error!("Error: {:?}", self);
let status = match self {
Error::GenerateToken => StatusCode::INTERNAL_SERVER_ERROR,
Error::Database => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::BAD_REQUEST,
};
let mut response = axum::Json(ResponseError::Error(self.to_string())).into_response();
*response.status_mut() = status;
response
}
}
pub type Result<T, E = Error> = std::result::Result<T, E>;

View File

@ -1,40 +0,0 @@
mod api;
pub mod error;
pub mod utils;
use anyhow::anyhow;
use axum::{http::HeaderValue, routing::get, Extension, Router, Server};
use tower_http::cors::{AllowOrigin, CorsLayer};
use tracing::info;
use crate::{config::Config, database::Database};
pub async fn start_server(config: Config, db: Database) -> anyhow::Result<()> {
info!(
"🚀 Server has launched on http://{}:{}",
config.http.host, config.http.port
);
// change the type from Vec<String> to Vec<HeaderValue> so that the http server can correctly detect CORS hosts
let origins = config
.http
.cors
.iter()
.map(|e| e.parse().expect("Failed to parse CORS hosts"))
.collect::<Vec<HeaderValue>>();
let host = format!("{}:{}", config.http.host, config.http.port);
let app = Router::new()
.nest("/api", api::app())
.route("/", get(api::health))
.layer(CorsLayer::new().allow_origin(AllowOrigin::list(origins)))
.layer(Extension(config))
.layer(Extension(db));
Server::bind(&host.parse()?)
.serve(app.into_make_service())
.await?;
Err(anyhow!("Server unexpected stopped!"))
}

View File

@ -1,2 +0,0 @@
pub mod token;
pub mod path;

View File

@ -1,29 +0,0 @@
use axum::extract::Query;
use serde::Deserialize;
use crate::server::error::{Error, Result};
pub type PathQuery = Query<Path>;
#[derive(Debug, Clone, Deserialize)]
pub struct Path {
pub path: String,
}
pub fn validate_path(path: PathQuery) -> Result<String> {
let path = path.path.clone();
// `path` can't contain `..`
// to prevent attack attempts because by using a `..` you can access the previous folder
if path.contains("..") {
return Err(Error::InvalidPath);
}
// `path` can't contain `~`
// to prevent attack attempts because `~` can get up a directory on `$HOME`
if path.contains('~') {
return Err(Error::InvalidPath);
}
Ok(path)
}

View File

@ -1,60 +0,0 @@
use axum::{
async_trait,
extract::FromRequestParts,
http::{header::AUTHORIZATION, request::Parts},
};
use crypto_utils::jsonwebtoken;
use crate::{
config::Config,
database::{error::Error as DatabaseError, Database, User},
server::error::Error,
};
pub struct Token(pub User);
#[async_trait]
impl<S> FromRequestParts<S> for Token
where
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(req: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let extensions = &req.extensions;
let config = extensions.get::<Config>().unwrap();
let db = extensions.get::<Database>().unwrap();
// Get authorisation header
let authorisation = req
.headers
.get(AUTHORIZATION)
.ok_or(Error::MissingHeader("Authorization"))?
.to_str()
.map_err(|_| Error::InvalidToken)?;
// Check that its a well-formed bearer
let split = authorisation.split_once(' ');
let token = match split {
Some((name, contents)) if name == "Bearer" => contents.to_string(),
_ => return Err(Error::MissingBearer),
};
let token = match jsonwebtoken::Token::decode(config.jwt.secret.as_bytes(), token) {
Ok(token) => token,
Err(_) => return Err(Error::InvalidToken),
};
let user = match db.find_user_by_id(&token.claims.sub).await {
Ok(user) => user,
Err(err) => match err {
DatabaseError::UserNotFound => return Err(Error::UserNotFound),
_ => return Err(Error::Database),
},
};
Ok(Self(user))
}
}

View File

@ -1,64 +0,0 @@
//! [Configuration file types](Config).
use std::fs;
use serde::{Deserialize, Serialize};
/// Settings configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// HTTP Settings.
pub http: ConfigHTTP,
/// Json Web Token Settings.
pub jwt: ConfigJWT,
/// Storage Settings.
pub storage: ConfigStorage,
}
/// HTTP Settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigHTTP {
/// HTTP Host.
pub host: String,
/// Port HTTP Port.
pub port: u16,
/// [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) Domains (e.g ["site1.example.com", "site2.example.com"]).
pub cors: Vec<String>,
}
/// Json Web Token Settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigJWT {
/// JWT Secret string (used to sign tokens).
pub secret: String,
/// Token expiration time in hours.
pub expires: i64,
}
/// Storage Settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigStorage {
/// Directory where user files will be stored.
pub path: String,
}
impl Config {
/// Parse configuration file.
pub fn parse(path: &str) -> anyhow::Result<Self> {
let config_str = fs::read_to_string(path)?;
let config = toml::from_str(&config_str)?;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test default configuration file.
#[test]
fn test_config() {
Config::parse("./config.toml").expect("Failed to parse configuration file");
}
}

20
types/Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "homedisk-types"
version = "0.0.0"
edition = "2021"
[features]
config = ["toml", "dirs"]
database = ["crypto-utils", "hex", "sqlx"]
[dependencies]
thiserror = "1.0.32"
uuid = "1.1.2"
anyhow = "1.0.62"
serde = { version = "1.0.144", features = ["derive"] }
axum = { version = "0.5.15", optional = true }
toml = { version = "0.5.9", optional = true }
dirs = { version = "4.0.0", optional = true }
crypto-utils = { version = "0.4.0", features = ["sha"], optional = true }
hex = { version = "0.4.3", optional = true }
sqlx = { version = "0.6.1", features = ["sqlite"], optional = true }

16
types/src/auth/login.rs Normal file
View File

@ -0,0 +1,16 @@
//! HTTP `/auth/login` Request and Response types
use serde::{Deserialize, Serialize};
/// HTTP `/auth/login` Request
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Request {
pub username: String,
pub password: String,
}
/// HTTP `/auth/login` Response
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Response {
pub access_token: String,
}

4
types/src/auth/mod.rs Normal file
View File

@ -0,0 +1,4 @@
//! HTTP `/auth/*` types for Request and Response
pub mod login;
pub mod whoami;

10
types/src/auth/whoami.rs Normal file
View File

@ -0,0 +1,10 @@
//! HTTP `/auth/whoami` Response type
use serde::{Deserialize, Serialize};
/// HTTP `/auth/whoami` Response
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Response {
/// Logged user username
pub username: String,
}

26
types/src/config/mod.rs Normal file
View File

@ -0,0 +1,26 @@
//! # Configuration file
//!
//! Path to a config file is `config.toml` in the work directory.
//!
//! ## Example config
//!
//! ```toml
//! [http]
//! host = "0.0.0.0" # HTTP Host
//! port = 8080 # HTTP Port
//! cors = ["homedisk.medzik.xyz"] # Domains allowed for CORS
//!
//! [jwt]
//! secret = "secret key used to sign tokens" # JWT Secret string (used to sign tokens)
//! expires = 24 # Token expiration time (in hours)
//!
//! [storage]
//! path = "/home/homedisk" # # Directory where user files will be stored
//! ```
//!
//! ### External docs
//! - [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
mod types;
pub use types::*;

55
types/src/config/types.rs Normal file
View File

@ -0,0 +1,55 @@
use std::fs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Configure HTTP settings
pub http: ConfigHTTP,
/// Configure Json Web Token settings
pub jwt: ConfigJWT,
/// Configure storage settings
pub storage: ConfigStorage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigHTTP {
/// HTTP Host
pub host: String,
/// Port HTTP Port
pub port: u16,
/// [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) Domains (e.g ["site1.example.com", "site2.example.com"])
pub cors: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigJWT {
/// JWT Secret string (used to sign tokens)
pub secret: String,
/// Token expiration time in hours
pub expires: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigStorage {
/// Directory where user files will be stored
pub path: String,
}
#[cfg(feature = "config")]
impl Config {
/// Parse configuration file.
///
/// ```no_run
/// use homedisk_types::config::Config;
///
/// let config = Config::parse().unwrap();
/// ```
pub fn parse() -> anyhow::Result<Self> {
let config_str = fs::read_to_string("config.toml")?;
let config = toml::from_str(&config_str)?;
Ok(config)
}
}

View File

@ -0,0 +1,5 @@
//! Types for a database
mod user;
pub use user::*;

138
types/src/database/user.rs Normal file
View File

@ -0,0 +1,138 @@
use crypto_utils::sha::{Algorithm, CryptographicHash};
use uuid::Uuid;
/// SQL user table
#[derive(Debug, sqlx::FromRow)]
pub struct User {
/// UUID of the user
pub id: String,
/// Username
pub username: String,
/// Encryped user password
pub password: String,
}
impl User {
/// The function create a unique user UUID and create SHA-512 hash from salted user password
/// and returns the [User] type.
///
/// **Note: This doesn't create a user in the database!**
///
/// ```
/// use homedisk_types::database::User;
///
/// let user = User::new("medzik", "SuperSecretPassword123!");
///
/// # assert_eq!(user.username, "medzik")
/// ```
pub fn new(username: &str, password: &str) -> Self {
// change username to lowercase
let username = username.to_lowercase();
// salting the password
let password = format!("{username}${password}");
// hash password using SHA-512 and encode it to String from Vec<u8>
let password = hex::encode(CryptographicHash::hash(
Algorithm::SHA512,
password.as_bytes(),
));
// generate a user UUID
let id_sha1 = CryptographicHash::hash(
Algorithm::SHA1,
(format!("{username}${password}")).as_bytes(),
);
let id = Uuid::new_v5(&Uuid::NAMESPACE_X500, &id_sha1).to_string();
// return `User`
Self {
id,
username,
password,
}
}
/// The function returns the directory where the user file is located.
///
/// ```
/// use homedisk_types::database::User;
///
/// let user = User::new("medzik", "whatever");
///
/// let dir = user.user_dir("/storage"); // will return `/storage/medzik`
///
/// assert_eq!(dir, "/storage/medzik")
/// ```
pub fn user_dir(&self, storage: &str) -> String {
// get a user storage path
let path = format!(
"{path}/{username}",
path = storage,
username = self.username,
);
// return user storage path
path
}
}
#[cfg(test)]
mod tests {
use crypto_utils::sha::{Algorithm, CryptographicHash};
use super::User;
/// Check if the id is reproducable
#[test]
fn check_id_reproducable() {
// example user data
let username = "test";
let password = "password";
let user_a = User::new(username, password);
let user_b = User::new(username, password);
assert_eq!(user_a.id, user_b.id)
}
/// Check if the username is in lowercase
#[test]
fn check_username_is_in_lowercase() {
// example user data
let username = "mEDZIk";
let password = "password";
// username in lowercase (expected username)
let username_expected = "medzik";
// create a new `User` type
let user = User::new(username, password);
// username validation with expected username
assert_eq!(user.username, username_expected)
}
/// Check that the password is a checksum with a salt
#[test]
fn check_if_password_is_hashed_and_salted() {
// example user data
let username = "username";
let password = "password";
// create a new `User` type
let user = User::new(username, password);
// expected password salt (string)
let password_expected_salt = format!("{username}${password}");
// expected password (hashed)
let password_expected = hex::encode(CryptographicHash::hash(
Algorithm::SHA512,
password_expected_salt.as_bytes(),
));
// password validation with expected password salt
assert_eq!(user.password, password_expected)
}
}

22
types/src/errors/auth.rs Normal file
View File

@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
pub enum Error {
#[error("user not found")]
UserNotFound,
#[error("user already exists")]
UserAlreadyExists,
#[error("username is too short")]
UsernameTooShort,
#[error("username is too long")]
UsernameTooLong,
#[error("password is too short")]
PasswordTooShort,
#[error("failed to generate jwt token")]
TokenGenerate,
#[error("invalid jwt token")]
InvalidToken,
#[error("other error - {0}")]
Other(String),
}

View File

@ -11,8 +11,6 @@ pub enum Error {
ConnectDatabase(sqlx::Error),
#[error("failed to get row: {0}")]
GetRow(sqlx::Error),
#[error("failed to create all required tables: {0}")]
CreateTables(sqlx::Error),
#[error("failed to execute the query: {0}")]
Execute(sqlx::Error),
}

30
types/src/errors/fs.rs Normal file
View File

@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
pub enum Error {
#[error("file doesn't exists")]
FileDoesNotExist,
#[error("file already exists")]
FileAlreadyExists,
#[error("unexpected multipart error")]
MultipartError,
#[error("failed to create create a file - {0}")]
CreateFile(String),
#[error("failed to create a directory - {0}")]
CreateDirectory(String),
#[error("failed to delete file: {0}")]
DeleteFile(String),
#[error("failed to delete directory: {0}")]
DeleteDirectory(String),
#[error("failed to write content to file: {0}")]
WriteFile(String),
#[error("failed to decode base64: {0}")]
Base64(String),
#[error("failed to read directory: {0}")]
ReadDirectory(String),
#[error("failed to read file content: {0}")]
ReadFile(String),
#[error("other error - {0}")]
Other(String),
}

12
types/src/errors/mod.rs Normal file
View File

@ -0,0 +1,12 @@
//! Error types
mod auth;
#[cfg(feature = "database")]
mod database;
mod fs;
mod server;
pub use auth::Error as AuthError;
pub use database::{Error as DatabaseError, Result as DatabaseResult};
pub use fs::Error as FsError;
pub use server::Error as ServerError;

View File

@ -0,0 +1,71 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::{AuthError, FsError};
/// HTTP Server Error
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
#[serde(tag = "error", content = "error_message", rename_all = "kebab-case")]
pub enum Error {
#[error("auth error: {0}")]
AuthError(#[from] AuthError),
#[error("fs error: {0}")]
FsError(#[from] FsError),
#[error("too may requests, please slow down")]
TooManyRequests,
#[error("invalid Content-Type")]
InvalidContentType,
#[error("failed to deserialize json")]
JsonDataError,
#[error("syntax error in json")]
JsonSyntaxError,
#[error("failed to extract the request body")]
BytesRejection,
#[error("other error: {0}")]
Other(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum ResponseError {
Error(String),
}
impl Error {
fn into_response(self) -> ResponseError {
ResponseError::Error(self.to_string())
}
}
#[cfg(feature = "axum")]
impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
use axum::http::StatusCode;
let status = match self {
Self::AuthError(ref err) => match err {
AuthError::TokenGenerate => StatusCode::INTERNAL_SERVER_ERROR,
AuthError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::BAD_REQUEST,
},
Self::FsError(ref err) => match err {
FsError::CreateFile(_) => StatusCode::INTERNAL_SERVER_ERROR,
FsError::CreateDirectory(_) => StatusCode::INTERNAL_SERVER_ERROR,
FsError::DeleteFile(_) => StatusCode::INTERNAL_SERVER_ERROR,
FsError::DeleteDirectory(_) => StatusCode::INTERNAL_SERVER_ERROR,
FsError::WriteFile(_) => StatusCode::INTERNAL_SERVER_ERROR,
FsError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::BAD_REQUEST,
},
Self::TooManyRequests => StatusCode::TOO_MANY_REQUESTS,
Self::BytesRejection => StatusCode::INTERNAL_SERVER_ERROR,
Self::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::BAD_REQUEST,
};
let mut response = axum::Json(self.into_response()).into_response();
*response.status_mut() = status;
response
}
}

View File

@ -0,0 +1,10 @@
//! HTTP `/fs/createdir` Request and Response types
use serde::{Deserialize, Serialize};
/// HTTP `/fs/createdir` Request
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Request {
/// Path to directory wich will be created
pub path: String,
}

10
types/src/fs/delete.rs Normal file
View File

@ -0,0 +1,10 @@
//! HTTP `/fs/delete` Request type
use serde::{Deserialize, Serialize};
/// HTTP `/fs/delete` Request
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Request {
/// Path of file/directory to be delete
pub path: String,
}

10
types/src/fs/download.rs Normal file
View File

@ -0,0 +1,10 @@
//! HTTP `/fs/download` Request type
use serde::{Deserialize, Serialize};
/// HTTP `/fs/download` Request
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Request {
/// Path of file to be download
pub path: String,
}

39
types/src/fs/list.rs Normal file
View File

@ -0,0 +1,39 @@
//! HTTP `/fs/list` Request, Response, FileInfo and DirInfo types
use serde::{Deserialize, Serialize};
/// HTTP `/fs/list` Request
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Request {
/// Path to directory
pub path: String,
}
/// HTTP `/fs/list` Response
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Response {
/// Vector with files info
pub files: Vec<FileInfo>,
/// Vector with directories info
pub dirs: Vec<DirInfo>,
}
/// Info about a file
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FileInfo {
/// File name
pub name: String,
/// File size
pub size: String,
/// Latest modification of this file
pub modified: u64,
}
/// Info about a directory
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DirInfo {
/// Directory name
pub name: String,
/// Directory size (size of all files in directory)
pub size: String,
}

7
types/src/fs/mod.rs Normal file
View File

@ -0,0 +1,7 @@
//! HTTP `/fs/*` types for Request and Response
pub mod create_dir;
pub mod delete;
pub mod download;
pub mod list;
pub mod upload;

10
types/src/fs/upload.rs Normal file
View File

@ -0,0 +1,10 @@
//! HTTP `/fs/upload` Request and Response types
use serde::{Deserialize, Serialize};
/// HTTP `/fs/upload` Queries
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Pagination {
/// Path where the file will be uploaded
pub path: String,
}

8
types/src/lib.rs Normal file
View File

@ -0,0 +1,8 @@
#![doc(html_root_url = "https://homedisk-doc.medzik.xyz")]
pub mod auth;
pub mod config;
#[cfg(feature = "database")]
pub mod database;
pub mod errors;
pub mod fs;