refactor: rewrite

pull/73/head
MedzikUser 9 months ago
parent eef5cd8e02
commit 4dd9013521
No known key found for this signature in database
GPG Key ID: A5FAC1E185C112DB

577
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -1,8 +1,40 @@
[workspace]
members = [
"core",
"database",
"server",
"types",
]
resolver = "2"
[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.20", 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"] }
byte-unit = "4.0.14"

@ -1,22 +0,0 @@
[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"] }

@ -1,36 +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://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")]

@ -1,17 +0,0 @@
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();
}

@ -1,65 +0,0 @@
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");
}

@ -1,14 +0,0 @@
[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"] }

@ -1,9 +0,0 @@
#![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::*;

@ -1,286 +0,0 @@
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")
}
}

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

@ -1,19 +0,0 @@
[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"] }

@ -1,46 +0,0 @@
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))
}

@ -1,12 +0,0 @@
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))
}

@ -1,67 +0,0 @@
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))
}

@ -1,37 +0,0 @@
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))
}

@ -1,34 +0,0 @@
// 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)
}
}

@ -1,45 +0,0 @@
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(())
}

@ -1,55 +0,0 @@
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(())
}

@ -1,42 +0,0 @@
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)
}

@ -1,112 +0,0 @@
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 }))
}

@ -1,16 +0,0 @@
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))
}

@ -1,79 +0,0 @@
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(())
}

@ -1,44 +0,0 @@
#![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(())
}

@ -1,46 +0,0 @@
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();
}
}

@ -1,49 +0,0 @@
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();
}
}

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

@ -1,20 +0,0 @@
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())),
}
}

@ -1,40 +0,0 @@
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());
}
}

@ -11,6 +11,8 @@ 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),
}

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

@ -0,0 +1,174 @@
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