diff --git a/README.md b/README.md index 126a955..e445a16 100644 --- a/README.md +++ b/README.md @@ -19,21 +19,21 @@ Still very much an early WIP. ### Roadmap -- [ ] Delete pools - [ ] Logging and improved error handling - [ ] Lossless compression - [ ] Caching - [ ] Text media - [ ] Improve CSS -- [ ] Garbage collection background tasks (tags, pools) +- [ ] Background tasks for garbage collection (dangling tags) - [ ] User management +- [ ] Testing - [ ] Migrate to Cot...? ## Running -### Prerequisites +### Dependencies -- `ffmpeg` and `ffprobe` +- `ffmpeg` (with `ffprobe`) ### Development diff --git a/src/error.rs b/src/error.rs index b1dd47f..729f622 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,32 +8,46 @@ use axum::{ #[template(path = "pages/not_found.html")] struct NotFoundTemplate; +/// Errors from Samey. #[derive(Debug, thiserror::Error)] pub enum SameyError { + /// Integer conversion error. #[error("Integer conversion error: {0}")] IntConversion(#[from] std::num::TryFromIntError), + /// Integer parsing error. #[error("Integer parsing error: {0}")] IntParse(#[from] std::num::ParseIntError), + /// IO error. #[error("IO error: {0}")] IO(#[from] std::io::Error), + /// Task error. #[error("Task error: {0}")] Join(#[from] tokio::task::JoinError), + /// Template render error. #[error("Template render error: {0}")] Render(#[from] askama::Error), + /// Database error. #[error("Database error: {0}")] Database(#[from] sea_orm::error::DbErr), + /// File streaming error. #[error("File streaming error: {0}")] Multipart(#[from] axum::extract::multipart::MultipartError), + /// Image error. #[error("Image error: {0}")] Image(#[from] image::ImageError), - #[error("Not found")] - NotFound, + /// Authentication error. #[error("Authentication error: {0}")] Authentication(String), + /// Not found. + #[error("Not found")] + NotFound, + /// Not allowed. #[error("Not allowed")] Forbidden, + /// Bad request. #[error("Bad request: {0}")] BadRequest(String), + /// Custom internal error. #[error("Internal error: {0}")] Other(String), } diff --git a/src/lib.rs b/src/lib.rs index 4e36efd..edb4fab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +//! Sam's small image board. + pub(crate) mod auth; pub(crate) mod config; pub(crate) mod entities; @@ -63,14 +65,26 @@ pub(crate) struct AppState { app_config: Arc>, } +/// Helper function to create a single user. +/// +/// You can specify if they must be an admin user via the `is_admin` flag. +/// +/// ``` +/// use samey::create_user; +/// +/// # async fn _main() { +/// let db = sea_orm::Database::connect("sqlite:db.sqlite3?mode=rwc").await.unwrap(); +/// create_user(db, "admin", "secretPassword", true).await.expect("Unable to add admin user"); +/// # } +/// ``` pub async fn create_user( db: DatabaseConnection, - username: String, - password: String, + username: &str, + password: &str, is_admin: bool, ) -> Result<(), SameyError> { SameyUser::insert(samey_user::ActiveModel { - username: Set(username), + username: Set(username.into()), password: Set(generate_hash(password)), is_admin: Set(is_admin), ..Default::default() @@ -80,6 +94,18 @@ pub async fn create_user( Ok(()) } +/// Creates an Axum router for a Samey application. +/// +/// ``` +/// use samey::get_router; +/// +/// # async fn _main() { +/// let db = sea_orm::Database::connect("sqlite:db.sqlite3?mode=rwc").await.unwrap(); +/// let app = get_router(db, "files").await.unwrap(); +/// let listener = tokio::net::TcpListener::bind(("0.0.0.0", 3000)).await.unwrap(); +/// axum::serve(listener, app).await.unwrap(); +/// # } +/// ``` pub async fn get_router( db: DatabaseConnection, files_dir: impl AsRef, @@ -123,7 +149,7 @@ pub async fn get_router( .route_with_tsr("/pools", get(get_pools)) .route_with_tsr("/pools/{page}", get(get_pools_page)) .route_with_tsr("/pool", post(create_pool)) - .route_with_tsr("/pool/{pool_id}", get(view_pool)) + .route_with_tsr("/pool/{pool_id}", get(view_pool).delete(delete_pool)) .route_with_tsr("/pool/{pool_id}/name", put(change_pool_name)) .route_with_tsr("/pool/{pool_id}/public", put(change_pool_visibility)) .route_with_tsr("/pool/{pool_id}/post", post(add_post_to_pool)) diff --git a/src/main.rs b/src/main.rs index 8c8c2e9..cd5e95c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,7 +64,7 @@ async fn main() { } Commands::AddAdminUser { username, password } => { - create_user(db, username, password, true) + create_user(db, &username, &password, true) .await .expect("Unable to add admin user"); } diff --git a/src/video.rs b/src/video.rs index 554ea3c..62e9cae 100644 --- a/src/video.rs +++ b/src/video.rs @@ -8,7 +8,7 @@ pub(crate) fn generate_thumbnail( max_thumbnail_dimension: u32, ) -> Result<(), SameyError> { let status = Command::new("ffmpeg") - .args(&[ + .args([ "-i", input_path, "-vf", @@ -39,7 +39,7 @@ pub(crate) fn generate_thumbnail( pub(crate) fn get_dimensions_for_video(input_path: &str) -> Result<(u32, u32), SameyError> { let output = Command::new("ffprobe") - .args(&[ + .args([ "-v", "error", "-select_streams", diff --git a/src/views.rs b/src/views.rs index a104748..c8dd989 100644 --- a/src/views.rs +++ b/src/views.rs @@ -357,6 +357,7 @@ pub(crate) async fn upload( .read(true) .write(true) .create(true) + .truncate(true) .open(&file_path)?; while let Some(chunk) = field.chunk().await? { file.write_all(&chunk)?; @@ -407,6 +408,7 @@ pub(crate) async fn upload( .read(true) .write(true) .create(true) + .truncate(true) .open(&file_path)?; while let Some(chunk) = field.chunk().await? { file.write_all(&chunk)?; @@ -528,13 +530,13 @@ pub(crate) async fn search_tags( State(AppState { db, .. }): State, Form(body): Form, ) -> Result { - let tags = match body.tags[..body.selection_end].split(' ').last() { + let tags = match body.tags[..body.selection_end].split(' ').next_back() { Some(mut tag) => { tag = tag.trim(); if tag.is_empty() { vec![] - } else if tag.starts_with(NEGATIVE_PREFIX) { - if tag[NEGATIVE_PREFIX.len()..].starts_with(RATING_PREFIX) { + } else if let Some(stripped_tag) = tag.strip_prefix(NEGATIVE_PREFIX) { + if stripped_tag.starts_with(RATING_PREFIX) { [ format!("{}u", RATING_PREFIX), format!("{}s", RATING_PREFIX), @@ -542,7 +544,7 @@ pub(crate) async fn search_tags( format!("{}e", RATING_PREFIX), ] .into_iter() - .filter(|t| t.starts_with(&tag[NEGATIVE_PREFIX.len()..])) + .filter(|t| t.starts_with(stripped_tag)) .map(|tag| SearchTag { value: format!("-{}", &tag), name: tag, @@ -552,7 +554,7 @@ pub(crate) async fn search_tags( SameyTag::find() .filter(Expr::cust_with_expr( "LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')", - tag[NEGATIVE_PREFIX.len()..].to_lowercase(), + stripped_tag.to_lowercase(), )) .limit(10) .all(&db) @@ -1115,12 +1117,10 @@ pub(crate) async fn sort_pool( }; let max_index = if body.new_index == posts.len().saturating_sub(1) { None + } else if body.new_index < body.old_index { + Some(body.new_index) } else { - if body.new_index < body.old_index { - Some(body.new_index) - } else { - Some(body.new_index + 1) - } + Some(body.new_index + 1) }; let min = min_index.map(|index| posts[index].position).unwrap_or(0.0); let max = max_index @@ -1148,6 +1148,30 @@ pub(crate) async fn sort_pool( )) } +pub(crate) async fn delete_pool( + State(AppState { db, .. }): State, + auth_session: AuthSession, + Path(pool_id): Path, +) -> Result { + let pool = SameyPool::find_by_id(pool_id) + .one(&db) + .await? + .ok_or(SameyError::NotFound)?; + + let can_edit = match auth_session.user.as_ref() { + None => false, + Some(user) => user.is_admin || pool.uploader_id == user.id, + }; + + if !can_edit { + return Err(SameyError::Forbidden); + } + + SameyPool::delete_by_id(pool_id).exec(&db).await?; + + Ok(Redirect::to("/")) +} + // Bulk edit tag views enum BulkEditTagMessage { @@ -1629,14 +1653,14 @@ pub(crate) async fn submit_post_details( } let title = match body.title.trim() { - title if title.is_empty() => None, + "" => None, title => Some(title.to_owned()), }; let description = match body.description.trim() { - description if description.is_empty() => None, + "" => None, description => Some(description.to_owned()), }; - let parent_post = if let Some(parent_id) = body.parent_post.trim().parse().ok() { + let parent_post = if let Ok(parent_id) = body.parent_post.trim().parse() { match filter_posts_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref()) .one(&db) .await? diff --git a/templates/pages/pool.html b/templates/pages/pool.html index 74f5ba5..3135733 100644 --- a/templates/pages/pool.html +++ b/templates/pages/pool.html @@ -106,6 +106,16 @@ value="true" /> +
+ +
{% endif %}