mod logging; use std::collections::HashMap; use std::path::{Path, PathBuf}; use axum::body::BodyDataStream; use axum::extract::{Request, State}; use axum::http::StatusCode; use axum::Router; use axum::routing::post; use dotenv::{dotenv, var}; use futures::TryStreamExt; use log::LevelFilter; use systemd_journal_logger::JournalLog; use tokio::fs::File; use tokio::{fs, io}; use tokio::io::BufWriter; use tokio_util::io::StreamReader; use urlencoding::decode; use crate::logging::Logging; #[derive(Clone)] struct App { log: Logging, directories: HashMap } impl App { pub fn init_directories(&mut self) { let root_dir = match var("ROOT_DIRECTORY") { Ok(dir) => { self.log.info(format!("ROOT_DIRECTORY set to '{dir}'")); dir } Err(e) => { self.log.error(format!("ROOT_DIRECTORY not set: {e}. Aborting.")); panic!("ROOT_DIRECTORY not set: {e}. Aborting."); } }; let novel_dir = match var("NOVEL_DIRECTORY") { Ok(dir) => { self.log.info(format!("NOVEL_DIRECTORY set to '{root_dir}/{dir}'")); format!("{root_dir}/{dir}") } Err(e) => { self.log.error(format!("NOVEL_DIRECTORY not set: {e}. Defaulting to '{root_dir}/novels'.")); format!("{root_dir}/novels") } }; self.directories.insert("Novel".to_owned(), novel_dir); let manga_dir = match var("MANGA_DIRECTORY") { Ok(dir) => { self.log.info(format!("MANGA_DIRECTORY set to '{root_dir}/{dir}'")); format!("{root_dir}/{dir}") } Err(e) => { self.log.error(format!("MANGA_DIRECTORY not set: {e}. Defaulting to '{root_dir}/manga'.")); format!("{root_dir}/manga") } }; self.directories.insert("Manga".to_owned(), manga_dir); let hentai_dir = match var("HENTAI_DIRECTORY") { Ok(dir) => { self.log.info(format!("HENTAI_DIRECTORY set to '{root_dir}/{dir}'")); format!("{root_dir}/{dir}") } Err(e) => { self.log.error(format!("HENTAI_DIRECTORY not set: {e}. Defaulting to '{root_dir}/hentai'.")); format!("{root_dir}/hentai") } }; self.directories.insert("Hentai".to_owned(), hentai_dir); } } #[tokio::main] async fn main() { dotenv().expect("Failed to init dotenv"); JournalLog::new() .expect("Systemd-Logger crate error") .install() .expect("Systemd-Logger crate error"); match var("LOG_LEVEL") { Ok(level) => { match level.as_str() { "debug" => log::set_max_level(LevelFilter::Debug), "info" => log::set_max_level(LevelFilter::Info), _ => log::set_max_level(LevelFilter::Info), } } _ => log::set_max_level(LevelFilter::Info), } let mut app = App { log: Logging::new(None), directories: HashMap::new(), }; app.init_directories(); let api = Router::new() .route("/upload", post(|State(mut state): State, request: Request| async move { upload_file(&mut state, request).await; })) .with_state(app); let listener = tokio::net::TcpListener::bind("[::]:3000").await.unwrap(); axum::serve(listener, api).await.unwrap(); } #[derive(Debug)] struct FilePath { format: String, series: String, volume: String, extension: String } impl FilePath { fn new() -> Self { Self { format: "".to_owned(), series: "".to_owned(), volume: "".to_owned(), extension: "".to_owned() } } fn check_valid(&self) -> bool { if self.format == "" || self.series == "" || self.volume == "" || self.extension == "" { return false } return true } fn to_pathbuf(&self) -> PathBuf { Path::new(format!("{}/{}/{}/{}.{}", self.format, self.series, self.volume, self.volume, self.extension).as_str()).to_path_buf() } } async fn upload_file(state: &mut App, request: Request) { let params_raw: Vec<&str> = request.uri().query().unwrap().split('&').collect(); let mut file = FilePath::new(); params_raw.iter().for_each(|param| { let split: Vec<&str> = param.split('=').collect(); state.log.info(format!("Parsing Parameter Key-Value Pair '{param}'")); match split[0] { "format" => { file.format.clone_from(state.directories.get(split[1]).expect("Assume Valid Format Was Provided")); }, "series" => { file.series = decode(split[1]).expect("UTF-8").to_string(); }, "volume" => { file.volume = decode(split[1]).expect("UTF-8").to_string(); }, k => { state.log.warn(format!("Parameter {k} is not known and will be ignored")); } } }); let content_type = request.headers().get("Content-Type").expect("Content Type Should Have Been provided").to_str().expect("Content Type Should Be String"); file.extension = match content_type { "application/epub+zip" => "epub".to_owned(), "application/comic+zip" => "cbz".to_owned(), "application/pdf" => "pdf".to_owned(), ct => { state.log.error(format!("Invalid Content Type '{ct}' Provided, Aborting")); panic!("Invalid Content Type '{ct}'") } }; println!("{:#?}", file); if !file.check_valid() { //return Err((StatusCode::BAD_REQUEST, "Format not specified".to_owned())); } let pathbuf = file.to_pathbuf(); state.log.info(format!("File Path '{}'", pathbuf.clone().display())); let file_stream = request.into_body().into_data_stream(); if let Err(e) = stream_to_file(&pathbuf, file_stream).await { state.log.error(format!("{}: {}", e.0, e.1)); }; } async fn stream_to_file(path: &PathBuf, stream: BodyDataStream) -> Result<(), (StatusCode, String)> { if !Path::exists(path.parent().unwrap()) { fs::create_dir_all(path.parent().unwrap()).await.expect("Unable to Create Path"); } async { let body_with_io_error = stream.map_err(|err| io::Error::new(io::ErrorKind::Other, err)); let body_reader = StreamReader::new(body_with_io_error); futures::pin_mut!(body_reader); let mut file = BufWriter::new(File::create(path).await?); io::copy(&mut body_reader, &mut file).await?; Ok::<_, io::Error>(()) } .await .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) }