diff --git a/src/database/category.rs b/src/database/category.rs index 083618f..f974c77 100644 --- a/src/database/category.rs +++ b/src/database/category.rs @@ -1,9 +1,10 @@ -use camino::Utf8PathBuf; use chrono::Utc; use sea_orm::entity::prelude::*; use sea_orm::*; use snafu::prelude::*; +use std::str::FromStr; + use crate::database::operator::DatabaseOperator; use crate::database::{content_folder, operation::*}; use crate::extractors::normalized_path::*; @@ -38,8 +39,12 @@ impl ActiveModelBehavior for ActiveModel {} pub enum CategoryError { #[snafu(display("There is already a category called `{name}`"))] NameTaken { name: String }, + #[snafu(display("The category name is invalid. It must not contain slashes."))] + NameInvalid, #[snafu(display("There is already a category in dir `{path}`"))] PathTaken { path: String }, + #[snafu(display("The category path is invalid. It must be an absolute path."))] + PathInvalid, #[snafu(display("The parent directory does not exist: {path}"))] ParentDir { path: String }, #[snafu(display("Other disk error"))] @@ -177,8 +182,13 @@ impl CategoryOperator { /// /// - name or path is already taken (they should be unique) /// - path parent directory does not exist (to avoid completely wrong paths) - pub async fn create(&self, f: &CategoryForm) -> Result { - let dir = Utf8PathBuf::from(&f.path); + pub async fn create(&self, form: &CategoryForm) -> Result { + let name = NormalizedPathComponent::from_str(&form.name) + .map_err(|_e| CategoryError::NameInvalid)?; + let path = NormalizedPathAbsolute::from_str(&form.path) + .map_err(|_e| CategoryError::PathInvalid)?; + + let dir = path.to_path_buf(); let parent = dir.parent().unwrap(); if !tokio::fs::try_exists(parent).await.context(IOSnafu)? { @@ -190,20 +200,20 @@ impl CategoryOperator { // Check duplicates let list = self.list().await?; - if list.iter().any(|x| x.name == f.name) { + if list.iter().any(|x| x.name == name) { return Err(CategoryError::NameTaken { - name: f.name.to_string(), + name: name.to_string(), }); } - if list.iter().any(|x| x.path == f.path) { + if list.iter().any(|x| x.path == path) { return Err(CategoryError::PathTaken { - path: f.path.to_string(), + path: path.to_string(), }); } let model = ActiveModel { - name: Set(f.name.clone()), - path: Set(f.path.clone()), + name: Set(name.clone()), + path: Set(path.clone()), ..Default::default() } .save(&self.state.database) @@ -220,9 +230,9 @@ impl CategoryOperator { operation: OperationType::Create, operation_id: OperationId { object_id: model.id.to_owned(), - name: f.name.to_string(), + name: name.to_string(), }, - operation_form: Some(Operation::Category(f.clone())), + operation_form: Some(Operation::Category(form.clone())), }; self.state diff --git a/src/database/content_folder.rs b/src/database/content_folder.rs index 4d6ecaa..8c0bdc0 100644 --- a/src/database/content_folder.rs +++ b/src/database/content_folder.rs @@ -4,9 +4,12 @@ use sea_orm::entity::prelude::*; use sea_orm::*; use snafu::prelude::*; -use crate::database::category::{self, CategoryError, CategoryOperator}; +use std::str::FromStr; + +use crate::database::category::{self, CategoryError}; use crate::database::operation::{Operation, OperationId, OperationLog, OperationType, Table}; use crate::database::operator::DatabaseOperator; +use crate::extractors::normalized_path::{NormalizedPathAbsolute, NormalizedPathComponent}; use crate::extractors::user::User; use crate::routes::content_folder::ContentFolderForm; use crate::state::AppState; @@ -25,7 +28,9 @@ pub struct Model { pub id: i32, pub name: String, #[sea_orm(unique)] - pub path: String, + // TODO: maybe we'd like relative paths in fact? Why did + // we have a leading slash in the first place? + pub path: NormalizedPathAbsolute, pub category_id: i32, #[sea_orm(belongs_to, from = "category_id", to = "id")] pub category: HasOne, @@ -40,10 +45,12 @@ impl ActiveModelBehavior for ActiveModel {} #[derive(Debug, Snafu)] #[snafu(visibility(pub))] pub enum ContentFolderError { - #[snafu(display("There is already a content folder called `{name}`"))] + #[snafu(display("There is already a content folder called `{name}` in the current folder."))] NameTaken { name: String }, - #[snafu(display("There is already a content folder in dir `{path}`"))] - PathTaken { path: String }, + #[snafu(display("The folder name is invalid. It must not contain slashes."))] + NameInvalid, + #[snafu(display("The folder path must appear absolute"))] + PathInvalid, #[snafu(display("The Content Folder (Path: {path}) does not exist"))] NotFound { path: String }, #[snafu(display("Database error"))] @@ -52,6 +59,8 @@ pub enum ContentFolderError { Logger { source: LoggerError }, #[snafu(display("Category operation failed"))] Category { source: CategoryError }, + #[snafu(display("Failed to create the folder on disk"))] + IO { source: std::io::Error }, } #[derive(Clone, Debug)] @@ -90,6 +99,9 @@ impl ContentFolderOperator { /// /// Should not fail, unless SQLite was corrupted for some reason. pub async fn find_by_path(&self, path: String) -> Result { + let path = NormalizedPathAbsolute::from_str(&path) + .map_err(|_e| ContentFolderError::PathInvalid)?; + let content_folder = Entity::find_by_path(path.clone()) .one(&self.state.database) .await @@ -97,7 +109,9 @@ impl ContentFolderOperator { match content_folder { Some(category) => Ok(category), - None => Err(ContentFolderError::NotFound { path }), + None => Err(ContentFolderError::NotFound { + path: path.to_string(), + }), } } @@ -122,35 +136,48 @@ impl ContentFolderOperator { /// /// Fails if: /// - /// - name or path is already taken (they should be unique in one folder) + /// - name is already taken (they should be unique in one folder) /// - path parent directory does not exist (to avoid completely wrong paths) pub async fn create(&self, f: &ContentFolderForm) -> Result { - // Check duplicates in same folder - let list = if let Some(parent_id) = f.parent_id { - self.list_child_folders(parent_id).await? - } else { - let category = CategoryOperator::new(self.state.clone(), None); - category - .list_folders(f.category_id) - .await - .context(CategorySnafu)? - }; + let name = NormalizedPathComponent::from_str(&f.name) + .map_err(|_e| ContentFolderError::NameInvalid)?; - if list.iter().any(|x| x.name == f.name) { - return Err(ContentFolderError::NameTaken { - name: f.name.clone(), - }); - } + let category = self + .db() + .category() + .find_by_id(f.category_id) + .await + .context(CategorySnafu)?; - if list.iter().any(|x| x.path == f.path) { - return Err(ContentFolderError::PathTaken { - path: f.path.clone(), - }); + // Check duplicates in same category/folder + { + let siblings = if let Some(parent_id) = f.parent_id { + self.list_child_folders(parent_id).await? + } else { + self.db() + .category() + .list_folders(f.category_id) + .await + .context(CategorySnafu)? + }; + if siblings.iter().any(|x| x.name == f.name) { + return Err(ContentFolderError::NameTaken { + name: f.name.clone(), + }); + } } + // This path is an absolute path, but relative to a category path + let inner_path = if let Some(parent_id) = f.parent_id { + let parent = self.find_by_id(parent_id).await?; + NormalizedPathAbsolute::from_str(&format!("{}/{}", parent.path, name,)).unwrap() + } else { + NormalizedPathAbsolute::from_str(&format!("/{}", name)).unwrap() + }; + let model = ActiveModel { - name: Set(f.name.clone()), - path: Set(f.path.clone()), + name: Set(name.to_string()), + path: Set(inner_path.clone()), category_id: Set(f.category_id), parent_id: Set(f.parent_id), ..Default::default() @@ -159,6 +186,13 @@ impl ContentFolderOperator { .await .context(DBSnafu)?; + let real_path = + NormalizedPathAbsolute::from_str(&format!("{}{}", category.path, inner_path)).unwrap(); + + tokio::fs::create_dir_all(&real_path) + .await + .context(IOSnafu)?; + // Should not fail let model = model.try_into_model().unwrap(); diff --git a/src/extractors/category_request.rs b/src/extractors/category_request.rs index 154417c..238dbd7 100644 --- a/src/extractors/category_request.rs +++ b/src/extractors/category_request.rs @@ -7,6 +7,28 @@ use crate::database::content_folder::PathBreadcrumb; use crate::filesystem::FileSystemEntry; use crate::state::{AppState, error::*}; +#[derive(Clone, Debug)] +pub struct CategoriesRequest { + pub children: Vec, +} + +impl FromRequestParts for CategoriesRequest { + type Rejection = AppStateError; + + async fn from_request_parts( + _parts: &mut Parts, + app_state: &AppState, + ) -> Result { + let categories = CategoryOperator::new(app_state.clone(), None) + .list() + .await + .context(CategorySnafu)?; + + let children = FileSystemEntry::from_categories(&categories); + Ok(Self { children }) + } +} + #[derive(Clone, Debug)] pub struct CategoryRequest { pub category: category::Model, diff --git a/src/extractors/normalized_path/absolute.rs b/src/extractors/normalized_path/absolute.rs index b535238..b5fd38b 100644 --- a/src/extractors/normalized_path/absolute.rs +++ b/src/extractors/normalized_path/absolute.rs @@ -9,7 +9,7 @@ use std::str::FromStr; use super::*; /// [NormalizedPath] with extra constraint that it's absolute. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Hash, DeriveValueType)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash, DeriveValueType)] #[sea_orm(value_type = "String")] #[serde(into = "String", try_from = "String")] pub struct NormalizedPathAbsolute { diff --git a/src/extractors/normalized_path/component.rs b/src/extractors/normalized_path/component.rs index 2cd4cc1..ff55ba3 100644 --- a/src/extractors/normalized_path/component.rs +++ b/src/extractors/normalized_path/component.rs @@ -9,7 +9,7 @@ use std::str::FromStr; use super::*; /// [NormalizedPath] with extra constraint that it contains no slashes. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Hash, DeriveValueType)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash, DeriveValueType)] #[sea_orm(value_type = "String")] #[serde(into = "String", try_from = "String")] pub struct NormalizedPathComponent { diff --git a/src/extractors/normalized_path/mod.rs b/src/extractors/normalized_path/mod.rs index 1dcdce1..6e154f6 100644 --- a/src/extractors/normalized_path/mod.rs +++ b/src/extractors/normalized_path/mod.rs @@ -22,7 +22,7 @@ pub use relative::*; /// - disallows parent dir traversal (`..`) /// - has no trailing slash /// - may contain current dir `./` but these will be discarded -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Hash, DeriveValueType)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash, DeriveValueType)] #[sea_orm(value_type = "String")] #[serde(into = "String", try_from = "String")] pub struct NormalizedPath { diff --git a/src/extractors/normalized_path/relative.rs b/src/extractors/normalized_path/relative.rs index d01c8d1..8dd751e 100644 --- a/src/extractors/normalized_path/relative.rs +++ b/src/extractors/normalized_path/relative.rs @@ -9,7 +9,7 @@ use std::str::FromStr; use super::*; /// [NormalizedPath] with extra constraint that it's relative. -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Hash, DeriveValueType)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash, DeriveValueType)] #[sea_orm(value_type = "String")] #[serde(into = "String", try_from = "String")] pub struct NormalizedPathRelative { diff --git a/src/lib.rs b/src/lib.rs index 5ce54d4..7f0c705 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,13 +25,17 @@ pub fn router(state: state::AppState) -> Router { .route("/categories", post(routes::category::create)) .route("/categories/new", get(routes::category::new)) .route("/categories/{id}/delete", get(routes::category::delete)) - .route("/folders/{category_id}", get(routes::category::show)) + .route("/folders/{category}", get(routes::category::show)) + .route("/folders/{category}", post(routes::category::create_folder)) .route( "/folders/{category_name}/{*folder_path}", get(routes::content_folder::show), ) + .route( + "/folders/{category_name}/{*folder_path}", + post(routes::content_folder::create_subfolder), + ) .route("/folders", get(routes::index::index)) - .route("/folders", post(routes::content_folder::create)) .route("/logs", get(routes::logs::index)) // Register static assets routes .nest("/assets", static_router()) diff --git a/src/routes/category.rs b/src/routes/category.rs index d9e8103..df874a9 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -7,9 +7,10 @@ use serde::{Deserialize, Serialize}; use crate::database::category; use crate::database::content_folder::PathBreadcrumb; -use crate::extractors::category_request::CategoryRequest; -use crate::extractors::normalized_path::*; +use crate::extractors::category_request::{CategoriesRequest, CategoryRequest}; use crate::filesystem::FileSystemEntry; +use crate::routes::content_folder::ContentFolderForm; +use crate::routes::index::IndexTemplate; use crate::state::AppStateContext; use crate::state::flash_message::{ FallibleTemplate, FlashRedirect, FlashTemplate, OperationStatus, StatusCookie, @@ -17,8 +18,8 @@ use crate::state::flash_message::{ #[derive(Clone, Debug, Deserialize, Serialize)] pub struct CategoryForm { - pub name: NormalizedPathComponent, - pub path: NormalizedPathAbsolute, + pub name: String, + pub path: String, } #[derive(Template, WebTemplate)] @@ -55,21 +56,27 @@ pub async fn delete( pub async fn create( context: AppStateContext, + categories: CategoriesRequest, jar: CookieJar, Form(form): Form, -) -> FlashRedirect { - let status = match context.db.category().create(&form).await { - Ok(created) => StatusCookie::success( - jar, - format!( - "The category {} has been successfully created (ID {})", - created.name, created.id - ), - ), - Err(error) => StatusCookie::error(jar, error.to_string()), - }; - - status.redirect("/") +) -> Result { + match context.db.category().create(&form).await { + Ok(created) => { + let status = StatusCookie::success( + jar, + format!( + "The category {} has been successfully created (ID {})", + created.name, created.id + ), + ); + let uri = format!("/folders/{}", created.name); + Ok(status.redirect(&uri)) + } + Err(error) => { + let status = OperationStatus::error(error); + Err(status.with_template(IndexTemplate::new(context, categories))) + } + } } #[derive(Template, WebTemplate)] @@ -118,3 +125,29 @@ pub async fn show( ) -> FlashTemplate { status.with_template(CategoryShowTemplate::new(context, category)) } + +pub async fn create_folder( + context: AppStateContext, + jar: CookieJar, + category: CategoryRequest, + Form(form): Form, +) -> Result { + match context.db.content_folder().create(&form).await { + Ok(created) => { + let status = StatusCookie::success( + jar, + format!( + "The folder {} has been successfully created (ID: {})", + created.name, created.id + ), + ); + + let uri = format!("/folders/{}{}", category.category.name, created.path); + Ok(status.redirect(&uri)) + } + Err(error) => { + let status = OperationStatus::error(error); + Err(status.with_template(CategoryShowTemplate::new(context, category))) + } + } +} diff --git a/src/routes/content_folder.rs b/src/routes/content_folder.rs index 7016a78..3ea2788 100644 --- a/src/routes/content_folder.rs +++ b/src/routes/content_folder.rs @@ -1,26 +1,22 @@ use askama::Template; use askama_web::WebTemplate; use axum::Form; -use axum::response::Redirect; use axum_extra::extract::CookieJar; -use camino::Utf8PathBuf; use serde::{Deserialize, Serialize}; -use snafu::prelude::*; use crate::database::content_folder::PathBreadcrumb; use crate::database::{category, content_folder}; use crate::extractors::folder_request::FolderRequest; use crate::filesystem::FileSystemEntry; +use crate::state::AppStateContext; use crate::state::flash_message::{ FallibleTemplate, FlashRedirect, FlashTemplate, OperationStatus, StatusCookie, }; -use crate::state::{AppStateContext, error::*}; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ContentFolderForm { pub name: String, pub parent_id: Option, - pub path: String, pub category_id: i32, } @@ -75,56 +71,14 @@ pub async fn show( status.with_template(ContentFolderShowTemplate::new(context, folder)) } -pub async fn create( +pub async fn create_subfolder( context: AppStateContext, jar: CookieJar, - Form(mut form): Form, -) -> Result { - let categories = context.db.category(); - let content_folders = context.db.content_folder(); - - // build path with Parent folder path (or category path if parent is None) + folder.name - let parent_path = if let Some(parent_id) = form.parent_id { - let parent_folder = content_folders - .find_by_id(parent_id) - .await - .context(ContentFolderSnafu)?; - Utf8PathBuf::from(parent_folder.path) - } else { - Utf8PathBuf::new() - }; - - // Get folder category - let category: category::Model = categories - .find_by_id(form.category_id) - .await - .context(CategorySnafu)?; - - // If name contains "/" returns an error - if form.name.contains("/") { - let status = StatusCookie::error( - jar, - format!( - "Failed to create Folder, {} is not valid (it contains '/')", - form.name - ), - ); - - let uri = format!("/folders/{}{}", category.name, parent_path.into_string()); - return Ok(status.redirect(&uri)); - } - - // build final path with parent_path and path of form - form.path = format!("{}/{}", parent_path, form.name); - - let created = content_folders.create(&form).await; - - match created { + folder: FolderRequest, + Form(form): Form, +) -> Result { + match context.db.content_folder().create(&form).await { Ok(created) => { - tokio::fs::create_dir_all(format!("{}/{}", category.path, created.path.clone())) - .await - .context(IOSnafu)?; - let status = StatusCookie::success( jar, format!( @@ -133,10 +87,12 @@ pub async fn create( ), ); - let uri = format!("/folders/{}{}", category.name, created.path); + let uri = format!("/folders/{}{}", folder.category.name, created.path); Ok(status.redirect(&uri)) } - // TODO: why don't we produce an error here? - Err(_error) => Ok((jar, Redirect::to("/"))), + Err(error) => { + let status = OperationStatus::error(error); + Err(status.with_template(ContentFolderShowTemplate::new(context, folder))) + } } } diff --git a/src/routes/index.rs b/src/routes/index.rs index af58c9d..80fab3f 100644 --- a/src/routes/index.rs +++ b/src/routes/index.rs @@ -1,11 +1,11 @@ use askama::Template; use askama_web::WebTemplate; -use snafu::prelude::*; // TUTORIAL: https://github.com/SeaQL/sea-orm/blob/master/examples/axum_example/ +use crate::extractors::category_request::CategoriesRequest; use crate::filesystem::FileSystemEntry; -use crate::state::flash_message::{FallibleTemplate, FlashTemplate, OperationStatus, StatusCookie}; -use crate::state::{AppStateContext, error::*}; +use crate::state::AppStateContext; +use crate::state::flash_message::{FallibleTemplate, OperationStatus}; #[derive(Template, WebTemplate)] #[template(path = "index.html")] @@ -25,22 +25,15 @@ impl FallibleTemplate for IndexTemplate { } impl IndexTemplate { - pub async fn new(context: AppStateContext) -> Result { - let categories = context.db.category().list().await.context(CategorySnafu)?; - let children = FileSystemEntry::from_categories(&categories); - - Ok(Self { + pub fn new(context: AppStateContext, categories: CategoriesRequest) -> Self { + Self { state: context, flash: None, - children, - }) + children: categories.children, + } } } -pub async fn index( - context: AppStateContext, - status: StatusCookie, -) -> Result, AppStateError> { - let template = IndexTemplate::new(context).await?; - Ok(status.with_template(template)) +pub async fn index(context: AppStateContext, categories: CategoriesRequest) -> IndexTemplate { + IndexTemplate::new(context, categories) } diff --git a/src/state/flash_message.rs b/src/state/flash_message.rs index fa2419b..9e443b1 100644 --- a/src/state/flash_message.rs +++ b/src/state/flash_message.rs @@ -76,6 +76,11 @@ impl OperationStatus { message: MessageOrError::Message(message), } } + + pub fn with_template(self, mut template: T) -> T { + template.with_optional_flash(Some(self)); + template + } } /// An operation status passed as a cookie. diff --git a/src/state/logger.rs b/src/state/logger.rs index 3c5ebad..b84389c 100644 --- a/src/state/logger.rs +++ b/src/state/logger.rs @@ -131,12 +131,9 @@ mod tests { use super::*; use crate::database::operation::*; - use crate::extractors::normalized_path::*; use crate::extractors::user::User; use crate::routes::category::CategoryForm; - use std::str::FromStr; - #[tokio::test] async fn many_writers() { let mut set = JoinSet::new(); @@ -155,8 +152,8 @@ mod tests { object_id: 1, }, operation_form: Some(Operation::Category(CategoryForm { - name: NormalizedPathComponent::from_str("object").unwrap(), - path: NormalizedPathAbsolute::from_str("/path").unwrap(), + name: "object".to_string(), + path: "/path".to_string(), })), }; @@ -195,8 +192,8 @@ mod tests { object_id: 1, }, operation_form: Some(Operation::Category(CategoryForm { - name: NormalizedPathComponent::from_str("object").unwrap(), - path: NormalizedPathAbsolute::from_str("/path").unwrap(), + name: "object".to_string(), + path: "/path".to_string(), })), }; diff --git a/templates/categories/show.html b/templates/categories/show.html index 8223730..eda09b1 100644 --- a/templates/categories/show.html +++ b/templates/categories/show.html @@ -9,20 +9,18 @@ {% endblock %} {% block content_folder_form %} -
+
-
diff --git a/templates/content_folders/show.html b/templates/content_folders/show.html index 56c9a17..da22153 100644 --- a/templates/content_folders/show.html +++ b/templates/content_folders/show.html @@ -9,13 +9,12 @@ {% endblock %} {% block content_folder_form %} - +
@@ -23,7 +22,6 @@ -