refactor: rewrite

This commit is contained in:
MedzikUser 2022-09-14 19:51:12 +02:00
parent eef5cd8e02
commit 4dd9013521
No known key found for this signature in database
GPG Key ID: A5FAC1E185C112DB
69 changed files with 1322 additions and 2124 deletions

579
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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"] }

View File

@ -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")]

View File

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

View File

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

View File

@ -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"] }

View File

@ -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::*;

View File

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

View File

@ -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"] }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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::*;

View File

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

View File

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

View File

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

8
src/database/mod.rs Normal file
View File

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

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

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

93
src/database/user.rs Normal file
View File

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

77
src/logger.rs Normal file
View File

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

90
src/main.rs Normal file
View File

@ -0,0 +1,90 @@
//! 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

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

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

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

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

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

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

86
src/server/api/fs/list.rs Normal file
View File

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

14
src/server/api/fs/mod.rs Normal file
View File

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

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

15
src/server/api/mod.rs Normal file
View File

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

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

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

30
src/server/mod.rs Normal file
View File

@ -0,0 +1,30 @@
mod api;
pub mod error;
pub mod utils;
use anyhow::anyhow;
use axum::{routing::get, Extension, Router, Server};
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
);
let host = format!("{}:{}", config.http.host, config.http.port);
let app = Router::new()
.nest("/api", api::app())
.route("/", get(api::health))
.layer(Extension(config))
.layer(Extension(db));
Server::bind(&host.parse()?)
.serve(app.into_make_service())
.await?;
Err(anyhow!("Server unexpected stopped!"))
}

2
src/server/utils/mod.rs Normal file
View File

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

29
src/server/utils/path.rs Normal file
View File

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

60
src/server/utils/token.rs Normal file
View File

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

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

@ -0,0 +1,64 @@
//! [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");
}
}

View File

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

View File

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

View File

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

View File

@ -1,10 +0,0 @@
//! 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,
}

View File

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

View File

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

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

View File

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

View File

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

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

View File

@ -1,12 +0,0 @@
//! 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

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

@ -1,10 +0,0 @@
//! 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,
}

View File

@ -1,10 +0,0 @@
//! 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,
}

View File

@ -1,10 +0,0 @@
//! 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,
}

View File

@ -1,39 +0,0 @@
//! 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,
}

View File

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

View File

@ -1,10 +0,0 @@
//! 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,
}

View File

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