diff --git a/Cargo.lock b/Cargo.lock index 7ab3fce..6c13b3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,6 +370,19 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atom_syndication" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "never", + "quick-xml", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -953,6 +966,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "strsim", "syn 2.0.100", ] @@ -988,6 +1002,37 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.100", +] + [[package]] name = "digest" version = "0.10.7" @@ -1000,6 +1045,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "diligent-date-parser" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" +dependencies = [ + "chrono", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1356,6 +1410,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.5.0" @@ -1507,6 +1580,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", + "h2", "http", "http-body", "httparse", @@ -1974,14 +2048,6 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" -[[package]] -name = "migration" -version = "0.1.0" -dependencies = [ - "async-std", - "sea-orm-migration", -] - [[package]] name = "mime" version = "0.3.17" @@ -2042,6 +2108,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "never" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2498,6 +2570,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quote" version = "1.0.40" @@ -2780,6 +2862,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rss" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf" +dependencies = [ + "atom_syndication", + "derive_builder", + "never", + "quick-xml", +] + [[package]] name = "rust-embed" version = "8.7.0" @@ -2944,15 +3038,17 @@ dependencies = [ "futures-util", "image", "itertools 0.14.0", - "migration", "mime_guess", "password-auth", "pulldown-cmark", "rand 0.9.0", + "rss", "rust-embed", + "samey-migration", "sea-orm", "serde", "serde_json", + "strum 0.27.1", "thiserror 2.0.12", "time", "tokio", @@ -2960,6 +3056,14 @@ dependencies = [ "tower-sessions", ] +[[package]] +name = "samey-migration" +version = "0.1.0" +dependencies = [ + "async-std", + "sea-orm-migration", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3000,7 +3104,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum", + "strum 0.26.3", "thiserror 2.0.12", "time", "tracing", @@ -3575,6 +3679,28 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +[[package]] +name = "strum" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.100", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index 3694c23..07d02ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,14 +6,18 @@ rust-version = "1.85" license = "MIT" authors = ["Bad Manners "] readme = "README.md" +keywords = ["booru", "image-board", "gallery"] +categories = ["web-programming"] +description = "Sam's small image board" +repository = "https://github.com/BadMannersXYZ/samey" [workspace] -members = [".", "migration"] +members = ["migration"] [dependencies] askama = { version = "0.13.0", features = ["serde_json"] } async-trait = "0.1.88" -axum = { version = "0.8.3", features = ["multipart", "macros"] } +axum = { version = "0.8.3", features = ["http2", "multipart", "macros"] } axum-extra = { version = "0.10.1", features = ["form"] } axum-login = "0.17.0" chrono = "0.4.40" @@ -21,11 +25,12 @@ clap = "4.5.35" futures-util = "0.3.31" image = "0.25.6" itertools = "0.14.0" -migration = { path = "migration" } +samey-migration = { path = "./migration", version = "0.1.0" } mime_guess = "2.0.5" password-auth = "1.0.0" pulldown-cmark = "0.13.0" rand = "0.9.0" +rss = "2.0.12" rust-embed = { version = "8.7.0", features = ["axum", "debug-embed"] } sea-orm = { version = "1.1.8", features = [ "sqlx-sqlite", @@ -40,6 +45,7 @@ time = "0.3.41" tokio = { version = "1.44.1", features = ["full"] } tower-http = { version = "0.6.2", features = ["fs"] } tower-sessions = "0.14.0" +strum = { version = "0.27.1", features = ["derive"] } [profile.release] strip = true diff --git a/README.md b/README.md index 2499066..879aef7 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,35 @@ Sam's small image board. +[Check out a sample instance here!](https://samey.badmanners.xyz/) + ## Status Still very much an early WIP. -### Roadmap +### Features -- [ ] Logging -- [ ] Improved error handling -- [ ] Caching -- [ ] Lossless compression -- [ ] Bulk edit tags/Fix tag capitalization -- [ ] User management -- [ ] Cleanup/fixup background tasks +- Image and video posts. +- Tagging with autocompletion. +- Post pools. +- RSS feeds. + +### Possible roadmap + +- [ ] Display thumbnails on post selection - [ ] Text media +- [ ] Testing - [ ] Improve CSS +- [ ] User management +- [ ] Lossless compression - [ ] Migrate to Cot...? -## Prerequisites - -- `ffmpeg` and `ffprobe` - ## Running +### Dependencies + +- `ffmpeg` (with `ffprobe`) + ### Development ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 9fb11e0..eb37c5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: samey: image: badmanners/samey:latest + build: + context: . container_name: samey restart: unless-stopped ports: diff --git a/migration/Cargo.toml b/migration/Cargo.toml index cc2fe11..621dbd2 100644 --- a/migration/Cargo.toml +++ b/migration/Cargo.toml @@ -1,16 +1,19 @@ [package] -name = "migration" +name = "samey-migration" version = "0.1.0" -edition = "2021" -publish = false - -[lib] -name = "migration" -path = "src/lib.rs" +edition = "2024" +rust-version = "1.85" +license = "MIT" +authors = ["Bad Manners "] +readme = "README.md" +keywords = ["booru", "image-board", "gallery"] +categories = ["web-programming"] +description = "Migrations for Samey, Sam's small image board" +repository = "https://github.com/BadMannersXYZ/samey" [dependencies] -async-std = { version = "1", features = ["attributes", "tokio1"] } - -[dependencies.sea-orm-migration] -version = "1.1.8" -features = ["sqlx-sqlite", "runtime-tokio-rustls"] +async-std = { version = "1.13.1", features = ["attributes", "tokio1"] } +sea-orm-migration = { version = "1.1.8", features = [ + "sqlx-sqlite", + "runtime-tokio-rustls", +] } diff --git a/migration/src/main.rs b/migration/src/main.rs index c6b6e48..7c1e85c 100644 --- a/migration/src/main.rs +++ b/migration/src/main.rs @@ -2,5 +2,5 @@ use sea_orm_migration::prelude::*; #[async_std::main] async fn main() { - cli::run_cli(migration::Migrator).await; + cli::run_cli(samey_migration::Migrator).await; } diff --git a/src/auth.rs b/src/auth.rs index 975b075..0d1adf1 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,8 +1,8 @@ use std::fmt::Debug; use axum_login::{AuthUser, AuthnBackend, UserId}; -use migration::Expr; use password_auth::verify_password; +use samey_migration::Expr; use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use serde::Deserialize; use time::OffsetDateTime; @@ -221,3 +221,9 @@ impl ExpiredDeletion for SessionStorage { Ok(()) } } + +impl From> for SameyError { + fn from(value: axum_login::Error) -> Self { + value.into() + } +} diff --git a/src/config.rs b/src/config.rs index 17ecefa..8838d08 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,11 +6,13 @@ use crate::{ }; pub(crate) const APPLICATION_NAME_KEY: &str = "APPLICATION_NAME"; +pub(crate) const BASE_URL_KEY: &str = "BASE_URL"; pub(crate) const AGE_CONFIRMATION_KEY: &str = "AGE_CONFIRMATION"; #[derive(Clone)] pub(crate) struct AppConfig { pub(crate) application_name: String, + pub(crate) base_url: String, pub(crate) age_confirmation: bool, } @@ -24,6 +26,14 @@ impl AppConfig { Some(row) => row.data.as_str().unwrap_or("Samey").to_owned(), None => "Samey".to_owned(), }; + let base_url = match SameyConfig::find() + .filter(samey_config::Column::Key.eq(BASE_URL_KEY)) + .one(db) + .await? + { + Some(row) => row.data.as_str().unwrap_or("").to_owned(), + None => "".to_owned(), + }; let age_confirmation = match SameyConfig::find() .filter(samey_config::Column::Key.eq(AGE_CONFIRMATION_KEY)) .one(db) @@ -34,6 +44,7 @@ impl AppConfig { }; Ok(Self { application_name, + base_url, age_confirmation, }) } diff --git a/src/error.rs b/src/error.rs index d3bdda0..018456c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,54 +4,114 @@ use axum::{ response::{Html, IntoResponse, Response}, }; +#[derive(askama::Template)] +#[template(path = "pages/bad_request.html")] +struct BadRequestTemplate<'a> { + error: &'a str, +} + +#[derive(askama::Template)] +#[template(path = "pages/unauthorized.html")] +struct UnauthorizedTemplate; + +#[derive(askama::Template)] +#[template(path = "pages/forbidden.html")] +struct ForbiddenTemplate; + #[derive(askama::Template)] #[template(path = "pages/not_found.html")] struct NotFoundTemplate; +#[derive(askama::Template)] +#[template(path = "pages/internal_server_error.html")] +struct InternalServerErrorTemplate; + +/// 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), } impl IntoResponse for SameyError { fn into_response(self) -> Response { - println!("Server error - {}", &self); match &self { SameyError::IntConversion(_) + | SameyError::IntParse(_) | SameyError::IO(_) | SameyError::Join(_) | SameyError::Render(_) | SameyError::Database(_) | SameyError::Image(_) | SameyError::Other(_) => { - (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong!").into_response() - } - SameyError::Multipart(_) | SameyError::BadRequest(_) => { - (StatusCode::BAD_REQUEST, "Invalid request").into_response() + println!("Internal server error - {:?}", &self); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Html( + InternalServerErrorTemplate {} + .render() + .expect("shouldn't fail to render InternalServerErrorTemplate"), + ), + ) + .into_response() } + SameyError::Multipart(error) => ( + StatusCode::BAD_REQUEST, + Html( + BadRequestTemplate { + error: &error.body_text(), + } + .render() + .expect("shouldn't fail to render BadRequestTemplate"), + ), + ) + .into_response(), + SameyError::BadRequest(error) => ( + StatusCode::BAD_REQUEST, + Html( + BadRequestTemplate { error } + .render() + .expect("shouldn't fail to render BadRequestTemplate"), + ), + ) + .into_response(), SameyError::NotFound => ( StatusCode::NOT_FOUND, Html( @@ -61,10 +121,24 @@ impl IntoResponse for SameyError { ), ) .into_response(), - SameyError::Authentication(_) => { - (StatusCode::UNAUTHORIZED, "Not authorized").into_response() - } - SameyError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden").into_response(), + SameyError::Authentication(_) => ( + StatusCode::UNAUTHORIZED, + Html( + UnauthorizedTemplate {} + .render() + .expect("shouldn't fail to render UnauthorizedTemplate"), + ), + ) + .into_response(), + SameyError::Forbidden => ( + StatusCode::FORBIDDEN, + Html( + ForbiddenTemplate {} + .render() + .expect("shouldn't fail to render ForbiddenTemplate"), + ), + ) + .into_response(), } } } diff --git a/src/lib.rs b/src/lib.rs index 8bc2b5f..85e2c75 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,11 @@ +//! Sam's small image board. + pub(crate) mod auth; pub(crate) mod config; pub(crate) mod entities; pub(crate) mod error; pub(crate) mod query; -pub(crate) mod rating; +pub(crate) mod tags; pub(crate) mod video; pub(crate) mod views; @@ -33,9 +35,6 @@ use crate::entities::{prelude::SameyUser, samey_user}; pub use crate::error::SameyError; use crate::views::*; -pub(crate) const NEGATIVE_PREFIX: &str = "-"; -pub(crate) const RATING_PREFIX: &str = "rating:"; - #[derive(rust_embed::Embed)] #[folder = "static/"] struct Asset; @@ -63,14 +62,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 +91,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, @@ -92,7 +115,9 @@ pub async fn get_router( fs::create_dir_all(files_dir.as_ref()).await?; let session_store = SessionStorage::new(db.clone()); - let session_layer = SessionManagerLayer::new(session_store); + let session_layer = SessionManagerLayer::new(session_store).with_expiry( + tower_sessions::Expiry::OnInactivity(time::Duration::weeks(1)), + ); let auth_layer = AuthManagerLayerBuilder::new(Backend::new(db), session_layer).build(); Ok(Router::new() @@ -121,12 +146,14 @@ 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)) .route_with_tsr("/pool/{pool_id}/sort", put(sort_pool)) .route_with_tsr("/pool_post/{pool_post_id}", delete(remove_pool_post)) + // Bulk edit tag routes + .route_with_tsr("/bulk_edit_tag", get(bulk_edit_tag).post(edit_tag)) // Settings routes .route_with_tsr("/settings", get(settings).post(update_settings)) // Search routes @@ -134,6 +161,7 @@ pub async fn get_router( .route_with_tsr("/posts/{page}", get(posts_page)) // Other routes .route_with_tsr("/remove", delete(remove_field)) + .route("/posts.xml", get(rss_page)) .route("/", get(index)) .with_state(state) .nest_service("/files", ServeDir::new(files_dir)) diff --git a/src/main.rs b/src/main.rs index 8c11604..11ee87b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,54 +1,89 @@ +use std::{ + net::{IpAddr, Ipv6Addr}, + path::PathBuf, +}; + use clap::{Parser, Subcommand}; -use migration::{Migrator, MigratorTrait}; use samey::{create_user, get_router}; +use samey_migration::{Migrator, MigratorTrait}; use sea_orm::Database; #[derive(Parser)] struct Config { + #[arg(short, long, default_value = "sqlite:db.sqlite3?mode=rwc")] + database: String, + + #[arg(short, long, default_value = "files")] + files_directory: PathBuf, + #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Commands { - Run, + Run { + #[arg(short, long, default_value_t = IpAddr::V6(Ipv6Addr::UNSPECIFIED))] + address: IpAddr, + + #[arg(short, long, default_value_t = 3000)] + port: u16, + }, + Migrate, + AddAdminUser { #[arg(short, long)] username: String, + #[arg(short, long)] password: String, }, } +impl Default for Commands { + fn default() -> Self { + Commands::Run { + address: IpAddr::V6(Ipv6Addr::UNSPECIFIED), + port: 3000, + } + } +} + #[tokio::main] async fn main() { - let db = Database::connect("sqlite:db.sqlite3?mode=rwc") + let config = Config::parse(); + let db = Database::connect(config.database) .await .expect("Unable to connect to database"); - let config = Config::parse(); - match config.command { - Some(Commands::Migrate) => { + match config.command.unwrap_or_default() { + Commands::Migrate => { Migrator::up(&db, None) .await .expect("Unable to apply migrations"); } - Some(Commands::AddAdminUser { username, password }) => { - create_user(db, username, password, true) + + Commands::AddAdminUser { username, password } => { + create_user(db, &username, &password, true) .await .expect("Unable to add admin user"); } - Some(Commands::Run) | None => { + + Commands::Run { address, port } => { Migrator::up(&db, None) .await .expect("Unable to apply migrations"); - let app = get_router(db, "files") + let app = get_router(db, config.files_directory) .await .expect("Unable to start router"); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") + let listener = tokio::net::TcpListener::bind((address, port)) .await - .expect("Unable to listen to port"); - println!("Listening on http://localhost:3000"); + .expect("Unable to bind TCP listener"); + if address.is_ipv6() { + println!("Listening on http://[{}]:{}", address, port); + } else { + println!("Listening on http://{}:{}", address, port); + } axum::serve(listener, app).await.unwrap(); } } diff --git a/src/query.rs b/src/query.rs index a4d51e0..3b75647 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,25 +1,31 @@ use std::collections::HashSet; -use migration::{Expr, Query}; +use chrono::NaiveDateTime; +use samey_migration::{Expr, Query}; use sea_orm::{ - ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoSimpleExpr, - QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns, SelectModel, - Selector, + ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoIdentity, + IntoSimpleExpr, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns, + SelectModel, Selector, }; use crate::{ - NEGATIVE_PREFIX, RATING_PREFIX, SameyError, + SameyError, auth::User, entities::{ prelude::{SameyPool, SameyPoolPost, SameyPost, SameyTag, SameyTagPost}, samey_pool, samey_pool_post, samey_post, samey_tag, samey_tag_post, }, + tags::{MEDIA_TYPE_PREFIX, NEGATIVE_PREFIX, RATING_PREFIX}, }; #[derive(Debug, FromQueryResult)] pub(crate) struct PostOverview { pub(crate) id: i32, pub(crate) thumbnail: String, + pub(crate) media: String, + pub(crate) title: Option, + pub(crate) description: Option, + pub(crate) uploaded_at: NaiveDateTime, pub(crate) tags: Option, pub(crate) media_type: String, pub(crate) rating: String, @@ -33,17 +39,23 @@ pub(crate) fn search_posts( let mut exclude_tags = HashSet::::new(); let mut include_ratings = HashSet::::new(); let mut exclude_ratings = HashSet::::new(); + let mut include_types = HashSet::::new(); + let mut exclude_types = HashSet::::new(); if let Some(tags) = tags { - for mut tag in tags.iter().map(|tag| tag.to_lowercase()) { - if tag.starts_with(NEGATIVE_PREFIX) { - if tag.as_str()[NEGATIVE_PREFIX.len()..].starts_with(RATING_PREFIX) { - exclude_ratings - .insert(tag.split_off(NEGATIVE_PREFIX.len() + RATING_PREFIX.len())); + for tag in tags.iter().map(|tag| tag.to_lowercase()) { + if let Some(negative_tag) = tag.strip_prefix(NEGATIVE_PREFIX) { + if let Some(negative_rating_tag) = negative_tag.strip_prefix(RATING_PREFIX) { + exclude_ratings.insert(negative_rating_tag.into()); + } else if let Some(negative_type_tag) = negative_tag.strip_prefix(MEDIA_TYPE_PREFIX) + { + exclude_types.insert(negative_type_tag.into()); } else { - exclude_tags.insert(tag.split_off(NEGATIVE_PREFIX.len())); + exclude_tags.insert(negative_tag.into()); } - } else if tag.starts_with(RATING_PREFIX) { - include_ratings.insert(tag.split_off(RATING_PREFIX.len())); + } else if let Some(rating_tag) = tag.strip_prefix(RATING_PREFIX) { + include_ratings.insert(rating_tag.into()); + } else if let Some(type_tag) = tag.strip_prefix(MEDIA_TYPE_PREFIX) { + include_types.insert(type_tag.into()); } else { include_tags.insert(tag); } @@ -54,6 +66,10 @@ pub(crate) fn search_posts( let mut query = SameyPost::find() .select_only() .column(samey_post::Column::Id) + .column(samey_post::Column::Media) + .column(samey_post::Column::Title) + .column(samey_post::Column::Description) + .column(samey_post::Column::UploadedAt) .column(samey_post::Column::Thumbnail) .column(samey_post::Column::Rating) .column(samey_post::Column::MediaType) @@ -72,11 +88,21 @@ pub(crate) fn search_posts( if !exclude_ratings.is_empty() { query = query.filter(samey_post::Column::Rating.is_not_in(exclude_ratings)) } + if !include_types.is_empty() { + query = query.filter(samey_post::Column::MediaType.is_in(include_types)) + } + if !exclude_types.is_empty() { + query = query.filter(samey_post::Column::MediaType.is_not_in(exclude_types)) + } query } else { let mut query = SameyPost::find() .select_only() .column(samey_post::Column::Id) + .column(samey_post::Column::Media) + .column(samey_post::Column::Title) + .column(samey_post::Column::Description) + .column(samey_post::Column::UploadedAt) .column(samey_post::Column::Thumbnail) .column(samey_post::Column::Rating) .column(samey_post::Column::MediaType) @@ -134,6 +160,12 @@ pub(crate) fn search_posts( if !exclude_ratings.is_empty() { query = query.filter(samey_post::Column::Rating.is_not_in(exclude_ratings)) } + if !include_types.is_empty() { + query = query.filter(samey_post::Column::MediaType.is_in(include_types)) + } + if !exclude_types.is_empty() { + query = query.filter(samey_post::Column::MediaType.is_not_in(exclude_types)) + } query }; @@ -263,3 +295,18 @@ pub(crate) fn filter_posts_by_user( ), } } + +pub(crate) async fn clean_dangling_tags(db: &DatabaseConnection) -> Result<(), SameyError> { + let dangling_tags = SameyTag::find() + .select_column_as(samey_tag_post::Column::Id.count(), "count") + .left_join(SameyTagPost) + .group_by(samey_tag::Column::Id) + .having(Expr::column("count".into_identity()).eq(0)) + .all(db) + .await?; + SameyTag::delete_many() + .filter(samey_tag::Column::Id.is_in(dangling_tags.into_iter().map(|tag| tag.id))) + .exec(db) + .await?; + Ok(()) +} diff --git a/src/rating.rs b/src/rating.rs deleted file mode 100644 index b95b44c..0000000 --- a/src/rating.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::fmt::Display; - -#[derive(PartialEq, Eq, PartialOrd, Ord)] -pub(crate) enum Rating { - Unrated, - Safe, - Questionable, - Explicit, -} - -impl From for Rating { - fn from(value: String) -> Self { - match value.as_ref() { - "s" => Self::Safe, - "q" => Self::Questionable, - "e" => Self::Explicit, - _ => Self::Unrated, - } - } -} - -impl Display for Rating { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Rating::Unrated => f.write_str("Unrated"), - Rating::Safe => f.write_str("Safe"), - Rating::Questionable => f.write_str("Questionable"), - Rating::Explicit => f.write_str("Explicit"), - } - } -} diff --git a/src/tags.rs b/src/tags.rs new file mode 100644 index 0000000..3954e8f --- /dev/null +++ b/src/tags.rs @@ -0,0 +1,23 @@ +pub(crate) const NEGATIVE_PREFIX: &str = "-"; +pub(crate) const RATING_PREFIX: &str = "rating:"; +pub(crate) const MEDIA_TYPE_PREFIX: &str = "type:"; + +#[derive(strum::EnumIter, strum::Display, Debug)] +pub(crate) enum Rating { + #[strum(serialize = "u")] + Unrated, + #[strum(serialize = "s")] + Safe, + #[strum(serialize = "q")] + Questionable, + #[strum(serialize = "e")] + Explicit, +} + +#[derive(strum::EnumIter, strum::Display, Debug)] +pub(crate) enum MediaType { + #[strum(serialize = "image")] + Image, + #[strum(serialize = "video")] + Video, +} 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 9c99371..e41430a 100644 --- a/src/views.rs +++ b/src/views.rs @@ -17,19 +17,20 @@ use axum_extra::extract::Form; use chrono::Utc; use image::{GenericImageView, ImageFormat, ImageReader}; use itertools::Itertools; -use migration::{Expr, OnConflict}; use rand::Rng; +use samey_migration::{OnConflict, Query as MigrationQuery}; use sea_orm::{ ActiveValue::Set, ColumnTrait, Condition, EntityTrait, FromQueryResult, IntoSimpleExpr, ModelTrait, PaginatorTrait, QueryFilter, QuerySelect, }; use serde::Deserialize; +use strum::IntoEnumIterator; use tokio::{task::spawn_blocking, try_join}; use crate::{ - AppState, NEGATIVE_PREFIX, RATING_PREFIX, + AppState, auth::{AuthSession, Credentials, User}, - config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY}, + config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY, BASE_URL_KEY}, entities::{ prelude::{ SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag, @@ -40,9 +41,10 @@ use crate::{ }, error::SameyError, query::{ - PoolPost, PostOverview, PostPoolData, filter_posts_by_user, get_pool_data_for_post, - get_posts_in_pool, get_tags_for_post, search_posts, + PoolPost, PostOverview, PostPoolData, clean_dangling_tags, filter_posts_by_user, + get_pool_data_for_post, get_posts_in_pool, get_tags_for_post, search_posts, }, + tags::{MEDIA_TYPE_PREFIX, MediaType, NEGATIVE_PREFIX, RATING_PREFIX, Rating}, video::{generate_thumbnail, get_dimensions_for_video}, }; @@ -90,6 +92,63 @@ pub(crate) async fn index( )) } +// RSS view + +#[derive(Template)] +#[template(path = "fragments/rss_entry.html")] +struct RssEntryTemplate<'a> { + post: PostOverview, + base_url: &'a str, +} + +#[axum::debug_handler] +pub(crate) async fn rss_page( + State(AppState { app_config, db, .. }): State, + Query(query): Query, +) -> Result { + let app_config = app_config.read().await; + let application_name = app_config.application_name.clone(); + let base_url = app_config.base_url.clone(); + drop(app_config); + + let tags = query + .tags + .as_ref() + .map(|tags| tags.split_whitespace().collect::>()); + + let posts = search_posts(tags.as_ref(), None) + .paginate(&db, 20) + .fetch_page(0) + .await?; + + let channel = rss::ChannelBuilder::default() + .title(&application_name) + .link(&base_url) + .items( + posts + .into_iter() + .map(|post| { + rss::ItemBuilder::default() + .title(post.tags.clone()) + .pub_date(post.uploaded_at.and_utc().to_rfc2822()) + .link(format!("{}/post/{}", &base_url, post.id)) + .content( + RssEntryTemplate { + post, + base_url: &base_url, + } + .render() + .ok(), + ) + .build() + }) + .collect_vec(), + ) + .build(); + + Ok(channel.to_string()) +} + // Auth views #[derive(Template)] @@ -126,24 +185,17 @@ pub(crate) async fn login( mut auth_session: AuthSession, Form(credentials): Form, ) -> Result { - let user = match auth_session.authenticate(credentials).await { - Ok(Some(user)) => user, - Ok(None) => return Err(SameyError::Authentication("Invalid credentials".into())), - Err(_) => return Err(SameyError::Other("Auth session error".into())), + let user = match auth_session.authenticate(credentials).await? { + Some(user) => user, + None => return Err(SameyError::Authentication("Invalid credentials".into())), }; - auth_session - .login(&user) - .await - .map_err(|_| SameyError::Other("Login failed".into()))?; + auth_session.login(&user).await?; Ok(Redirect::to("/")) } pub(crate) async fn logout(mut auth_session: AuthSession) -> Result { - auth_session - .logout() - .await - .map_err(|_| SameyError::Other("Logout error".into()))?; + auth_session.logout().await?; Ok(Redirect::to("/")) } @@ -203,10 +255,9 @@ impl FromStr for Format { "application/x-matroska" | "video/mastroska" => Ok(Self::Video(".mkv")), "video/quicktime" => Ok(Self::Video(".mov")), _ => Ok(Self::Image( - ImageFormat::from_mime_type(content_type).ok_or(SameyError::Other(format!( - "Unknown content type: {}", - content_type - )))?, + ImageFormat::from_mime_type(content_type).ok_or(SameyError::BadRequest( + format!("Unknown content type: {}", content_type), + ))?, )), } } @@ -277,7 +328,7 @@ pub(crate) async fn upload( "media-file" => { let content_type = field .content_type() - .ok_or(SameyError::Other("Missing content type".into()))?; + .ok_or(SameyError::BadRequest("Missing content type".into()))?; match Format::from_str(content_type)? { format @ Format::Video(video_format) => { media_type = Some(format.media_type()); @@ -300,6 +351,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)?; @@ -350,6 +402,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)?; @@ -443,7 +496,9 @@ pub(crate) async fn upload( Ok(Redirect::to(&format!("/post/{}", uploaded_post))) } else { - Err(SameyError::Other("Missing parameters for upload".into())) + Err(SameyError::BadRequest( + "Missing parameters for upload".into(), + )) } } @@ -471,32 +526,43 @@ 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) { - [ - format!("{}u", RATING_PREFIX), - format!("{}s", RATING_PREFIX), - format!("{}q", RATING_PREFIX), - format!("{}e", RATING_PREFIX), - ] - .into_iter() - .filter(|t| t.starts_with(&tag[NEGATIVE_PREFIX.len()..])) - .map(|tag| SearchTag { - value: format!("-{}", &tag), - name: tag, - }) - .collect() + } else if let Some(stripped_tag) = tag.strip_prefix(NEGATIVE_PREFIX) { + if stripped_tag.starts_with(RATING_PREFIX) { + Rating::iter() + .map(|rating| format!("{}{}", RATING_PREFIX, rating)) + .filter(|t| t.starts_with(stripped_tag)) + .map(|tag| SearchTag { + value: format!("-{}", &tag), + name: tag, + }) + .collect() + } else if stripped_tag.starts_with(MEDIA_TYPE_PREFIX) { + MediaType::iter() + .map(|rating| format!("{}{}", MEDIA_TYPE_PREFIX, rating)) + .filter(|t| t.starts_with(stripped_tag)) + .map(|tag| SearchTag { + value: format!("-{}", &tag), + name: tag, + }) + .collect() } else { SameyTag::find() - .filter(Expr::cust_with_expr( - "LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')", - tag[NEGATIVE_PREFIX.len()..].to_lowercase(), - )) + .filter( + Condition::any() + .add( + samey_tag::Column::NormalizedName + .starts_with(stripped_tag.to_lowercase()), + ) + .add( + samey_tag::Column::NormalizedName + .contains(format!(":{}", stripped_tag.to_lowercase())), + ), + ) .limit(10) .all(&db) .await? @@ -508,25 +574,33 @@ pub(crate) async fn search_tags( .collect() } } else if tag.starts_with(RATING_PREFIX) { - [ - format!("{}u", RATING_PREFIX), - format!("{}s", RATING_PREFIX), - format!("{}q", RATING_PREFIX), - format!("{}e", RATING_PREFIX), - ] - .into_iter() - .filter(|t| t.starts_with(tag)) - .map(|tag| SearchTag { - value: tag.clone(), - name: tag, - }) - .collect() + Rating::iter() + .map(|rating| format!("{}{}", RATING_PREFIX, rating)) + .filter(|t| t.starts_with(tag)) + .map(|tag| SearchTag { + value: tag.clone(), + name: tag, + }) + .collect() + } else if tag.starts_with(MEDIA_TYPE_PREFIX) { + MediaType::iter() + .map(|rating| format!("{}{}", MEDIA_TYPE_PREFIX, rating)) + .filter(|t| t.starts_with(tag)) + .map(|tag| SearchTag { + value: tag.clone(), + name: tag, + }) + .collect() } else { SameyTag::find() - .filter(Expr::cust_with_expr( - "LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')", - tag.to_lowercase(), - )) + .filter( + Condition::any() + .add(samey_tag::Column::NormalizedName.starts_with(tag.to_lowercase())) + .add( + samey_tag::Column::NormalizedName + .contains(format!(":{}", tag.to_lowercase())), + ), + ) .limit(10) .all(&db) .await? @@ -1058,12 +1132,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 @@ -1091,12 +1163,170 @@ 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 { + None, + Success, + Failure(String), +} + +#[derive(Template)] +#[template(path = "pages/bulk_edit_tag.html")] +struct BulkEditTagTemplate { + application_name: String, + age_confirmation: bool, + message: BulkEditTagMessage, +} + +pub(crate) async fn bulk_edit_tag( + State(AppState { app_config, .. }): State, + auth_session: AuthSession, +) -> Result { + if auth_session.user.is_none_or(|user| !user.is_admin) { + return Err(SameyError::Forbidden); + } + + let app_config = app_config.read().await; + let application_name = app_config.application_name.clone(); + let age_confirmation = app_config.age_confirmation; + drop(app_config); + + Ok(Html( + BulkEditTagTemplate { + application_name, + age_confirmation, + message: BulkEditTagMessage::None, + } + .render()?, + )) +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EditTagForm { + tags: String, + new_tag: String, +} + +pub(crate) async fn edit_tag( + State(AppState { db, app_config, .. }): State, + auth_session: AuthSession, + Form(body): Form, +) -> Result { + if auth_session.user.is_none_or(|user| !user.is_admin) { + return Err(SameyError::Forbidden); + } + + let app_config = app_config.read().await; + let application_name = app_config.application_name.clone(); + let age_confirmation = app_config.age_confirmation; + drop(app_config); + + let old_tag: Vec<_> = body.tags.split_whitespace().collect(); + if old_tag.len() != 1 { + return Ok(Html( + BulkEditTagTemplate { + application_name, + age_confirmation, + message: BulkEditTagMessage::Failure("expected single tag to edit".into()), + } + .render()?, + )); + } + let old_tag = old_tag.first().unwrap(); + let normalized_old_tag = old_tag.to_lowercase(); + + let new_tag: Vec<_> = body.new_tag.split_whitespace().collect(); + if new_tag.len() != 1 { + return Ok(Html( + BulkEditTagTemplate { + application_name, + age_confirmation, + message: BulkEditTagMessage::Failure("expected single new tag".into()), + } + .render()?, + )); + } + let new_tag = new_tag.first().unwrap(); + let normalized_new_tag = new_tag.to_lowercase(); + + let old_tag_db = SameyTag::find() + .filter(samey_tag::Column::NormalizedName.eq(&normalized_old_tag)) + .one(&db) + .await? + .ok_or(SameyError::NotFound)?; + + if let Some(new_tag_db) = SameyTag::find() + .filter(samey_tag::Column::NormalizedName.eq(&normalized_new_tag)) + .one(&db) + .await? + { + let subquery = MigrationQuery::select() + .column((SameyTagPost, samey_tag_post::Column::PostId)) + .from(SameyTagPost) + .and_where(samey_tag_post::Column::TagId.eq(new_tag_db.id)) + .to_owned(); + SameyTagPost::update_many() + .filter(samey_tag_post::Column::TagId.eq(old_tag_db.id)) + .filter(samey_tag_post::Column::PostId.not_in_subquery(subquery)) + .set(samey_tag_post::ActiveModel { + tag_id: Set(new_tag_db.id), + ..Default::default() + }) + .exec(&db) + .await?; + SameyTag::delete_by_id(old_tag_db.id).exec(&db).await?; + } else { + SameyTag::update(samey_tag::ActiveModel { + id: Set(old_tag_db.id), + name: Set(new_tag.to_string()), + normalized_name: Set(normalized_new_tag), + }) + .exec(&db) + .await?; + } + + Ok(Html( + BulkEditTagTemplate { + application_name, + age_confirmation, + message: BulkEditTagMessage::Success, + } + .render()?, + )) +} + // Settings views #[derive(Template)] #[template(path = "pages/settings.html")] struct SettingsTemplate { application_name: String, + base_url: String, age_confirmation: bool, } @@ -1110,6 +1340,7 @@ pub(crate) async fn settings( let app_config = app_config.read().await; let application_name = app_config.application_name.clone(); + let base_url = app_config.base_url.clone(); let age_confirmation = app_config.age_confirmation; drop(app_config); @@ -1129,6 +1360,7 @@ pub(crate) async fn settings( Ok(Html( SettingsTemplate { application_name, + base_url, age_confirmation, } .render_with_values(&values)?, @@ -1138,11 +1370,18 @@ pub(crate) async fn settings( #[derive(Debug, Deserialize)] pub(crate) struct UpdateSettingsForm { application_name: String, + base_url: String, + favicon_post_id: String, age_confirmation: Option, } pub(crate) async fn update_settings( - State(AppState { db, app_config, .. }): State, + State(AppState { + db, + app_config, + files_dir, + .. + }): State, auth_session: AuthSession, Form(body): Form, ) -> Result { @@ -1152,18 +1391,27 @@ pub(crate) async fn update_settings( let mut configs = vec![]; - if !body.application_name.is_empty() { + let application_name = body.application_name.trim(); + if !application_name.is_empty() { let _ = mem::replace( &mut app_config.write().await.application_name, - body.application_name.clone(), + application_name.into(), ); configs.push(samey_config::ActiveModel { key: Set(APPLICATION_NAME_KEY.into()), - data: Set(body.application_name.into()), + data: Set(application_name.into()), ..Default::default() }); } + let base_url = body.base_url.trim_end_matches('/'); + let _ = mem::replace(&mut app_config.write().await.base_url, base_url.into()); + configs.push(samey_config::ActiveModel { + key: Set(BASE_URL_KEY.into()), + data: Set(base_url.into()), + ..Default::default() + }); + let age_confirmation = body.age_confirmation.is_some(); let _ = mem::replace( &mut app_config.write().await.age_confirmation, @@ -1186,6 +1434,21 @@ pub(crate) async fn update_settings( .await?; } + if let Some(favicon_post_id) = body.favicon_post_id.split_whitespace().next() { + match favicon_post_id.parse::() { + Ok(favicon_post_id) => { + let post = SameyPost::find_by_id(favicon_post_id) + .one(&db) + .await? + .ok_or(SameyError::NotFound)?; + ImageReader::open(files_dir.join(post.thumbnail))? + .decode()? + .save_with_format(files_dir.join("favicon.png"), ImageFormat::Png)?; + } + Err(err) => return Err(SameyError::IntParse(err)), + } + } + Ok(Redirect::to("/")) } @@ -1248,6 +1511,10 @@ pub(crate) async fn view_post_page( Some(parent_post) => Some(PostOverview { id: parent_id, thumbnail: parent_post.thumbnail, + title: parent_post.title, + description: parent_post.description, + uploaded_at: parent_post.uploaded_at, + media: parent_post.media, tags: Some( get_tags_for_post(post_id) .all(&db) @@ -1277,6 +1544,10 @@ pub(crate) async fn view_post_page( children_posts.push(PostOverview { id: child_post.id, thumbnail: child_post.thumbnail, + title: child_post.title, + description: child_post.description, + uploaded_at: child_post.uploaded_at, + media: child_post.media, tags: Some( get_tags_for_post(child_post.id) .all(&db) @@ -1371,6 +1642,7 @@ struct SubmitPostDetailsTemplate { parent_post: Option, sources: Vec, tags: Vec, + tags_text: String, can_edit: bool, } @@ -1395,14 +1667,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? @@ -1410,6 +1682,10 @@ pub(crate) async fn submit_post_details( Some(parent_post) => Some(PostOverview { id: parent_id, thumbnail: parent_post.thumbnail, + title: parent_post.title, + description: parent_post.description, + uploaded_at: parent_post.uploaded_at, + media: parent_post.media, tags: Some( get_tags_for_post(post_id) .all(&db) @@ -1497,17 +1773,31 @@ pub(crate) async fn submit_post_details( upload_tags.sort_by(|a, b| a.name.cmp(&b.name)); upload_tags }; + let mut tags_text = String::new(); + for tag in &tags { + if !tags_text.is_empty() { + tags_text.push(' '); + } + tags_text.push_str(&tag.name); + } let sources = SameyPostSource::find() .filter(samey_post_source::Column::PostId.eq(post_id)) .all(&db) .await?; + tokio::spawn(async move { + if let Err(err) = clean_dangling_tags(&db).await { + println!("Error when cleaning dangling tags - {}", err); + } + }); + Ok(Html( SubmitPostDetailsTemplate { post, sources, tags, + tags_text, parent_post, can_edit: true, } @@ -1615,9 +1905,11 @@ pub(crate) async fn delete_post( SameyPost::delete_by_id(post.id).exec(&db).await?; tokio::spawn(async move { - let base_path = files_dir.as_ref(); - let _ = std::fs::remove_file(base_path.join(post.media)); - let _ = std::fs::remove_file(base_path.join(post.thumbnail)); + let _ = std::fs::remove_file(files_dir.join(post.media)); + let _ = std::fs::remove_file(files_dir.join(post.thumbnail)); + if let Err(err) = clean_dangling_tags(&db).await { + println!("Error when cleaning dangling tags - {}", err); + } }); Ok(Redirect::to("/")) diff --git a/templates/fragments/common_headers.html b/templates/fragments/common_headers.html index 47eacf4..f02d8a9 100644 --- a/templates/fragments/common_headers.html +++ b/templates/fragments/common_headers.html @@ -1,5 +1,6 @@ + diff --git a/templates/fragments/edit_post_details.html b/templates/fragments/edit_post_details.html index 6fdf1ed..64f6616 100644 --- a/templates/fragments/edit_post_details.html +++ b/templates/fragments/edit_post_details.html @@ -1,46 +1,55 @@ -
-
- - {% let tags_value = tags %} {% include "fragments/tags_input.html" %} -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
      - {% for source in sources %} - {% include "fragments/post_source.html" %} - {% endfor %} -
    - -
    -
    - - -
    -
    - - - -
    - +
    + +
    + + {% let tags_value = tags %} {% include "fragments/tags_input.html" %} + +
      +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + +
        + {% for source in sources %} + {% include "fragments/post_source.html" %} + {% endfor %} +
      + +
      +
      + + +
      +
      + + + +
      + +
      \ No newline at end of file diff --git a/templates/fragments/get_video_media.html b/templates/fragments/get_video_media.html index 7e130c0..feff536 100644 --- a/templates/fragments/get_video_media.html +++ b/templates/fragments/get_video_media.html @@ -1,6 +1,7 @@ diff --git a/templates/fragments/post_details.html b/templates/fragments/post_details.html index 1283837..e399887 100644 --- a/templates/fragments/post_details.html +++ b/templates/fragments/post_details.html @@ -1,24 +1,23 @@ -
      +

      - {% if let Some(title) = post.title %}{{ title }}{% else %}Details{% - endif %} + {% if let Some(title) = post.title %}{{ title }}{% else %}Details{% endif %}

      {% if let Some(description) = post.description %}
      {{ description | markdown }}
      {% endif %} + {% if can_edit %} - + + {% endif %} @@ -54,6 +53,12 @@
      Is public post? - {% if post.is_public %}Yes{% else %}No{% endif %} - {% if post.is_public %}Yes{% else %}No{% endif %}
      Rating - {% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %} Safe - {% when "q" %} Questionable {% when "e" %} Explicit {% else %} Unknown {% - endmatch %} + {% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %} + Safe {% when "q" %} Questionable {% when "e" %} Explicit {% else %} + Unknown {% endmatch %}
      {% if can_edit %} - + {% endif %} - +
      diff --git a/templates/fragments/rss_entry.html b/templates/fragments/rss_entry.html new file mode 100644 index 0000000..246e3b3 --- /dev/null +++ b/templates/fragments/rss_entry.html @@ -0,0 +1,11 @@ +

      + {% if let Some(title) = post.title %}{{ title }}{% else %}Details{% endif %} +

      +{% match post.media_type.as_ref() %}{% when "image" %} + +{% when "video" %} + +{% else %}{% endmatch %}{% if let Some(description) = post.description %} +

      Description

      +
      {{ description | markdown }}
      +{% endif %} diff --git a/templates/fragments/search_tags.html b/templates/fragments/search_tags.html index c01593f..dfa176b 100644 --- a/templates/fragments/search_tags.html +++ b/templates/fragments/search_tags.html @@ -1,4 +1,10 @@ -{% for tag in tags %} +{% if !tags.is_empty() %} +
    • + +
    • +{% endif %}{% for tag in tags %}
    • {% endif %} - +
      +

      Tags

      + {% if tags.is_empty() %} +

      No tags in post. Consider adding some!

      + {% else %} + + {% endif %} +
      diff --git a/templates/fragments/tags_input.html b/templates/fragments/tags_input.html index 4fd09d9..4c6ad96 100644 --- a/templates/fragments/tags_input.html +++ b/templates/fragments/tags_input.html @@ -5,10 +5,11 @@ name="tags" placeholder="Tags" hx-post="/search_tags" - hx-trigger="input changed" + hx-trigger="input changed delay:500ms" hx-target="next .tags-autocomplete" - hx-vals="js:{selection_end: event.target.selectionEnd}" - hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);" + hx-swap="innerHTML" + hx-vals="js:{selection_end: document.querySelector('.tags').selectionEnd}" + hx-on::after-settle="document.querySelector('.tags').focus(); document.querySelector('.tags').setSelectionRange(-1, -1);" value="{{ tags_value }}" aria-autocomplete="list" aria-controls="search-autocomplete" diff --git a/templates/pages/bad_request.html b/templates/pages/bad_request.html new file mode 100644 index 0000000..6ae29a3 --- /dev/null +++ b/templates/pages/bad_request.html @@ -0,0 +1,14 @@ + + + + Bad request + {% include "fragments/common_headers.html" %} + + + +
      +

      Bad request

      +

      The provided parameters are invalid: {{ error }}

      +
      + + diff --git a/templates/pages/bulk_edit_tag.html b/templates/pages/bulk_edit_tag.html new file mode 100644 index 0000000..0d06b0f --- /dev/null +++ b/templates/pages/bulk_edit_tag.html @@ -0,0 +1,37 @@ + + + + Bulk edit tag - {{ application_name }} + + {% include "fragments/common_headers.html" %} + + + {% if age_confirmation %}{% include "fragments/age_restricted_check.html" + %}{% endif %} + +
      +

      Bulk edit tag

      +
      +

      Select tag to edit

      +
      + {% let tags_value = "" %} {% include "fragments/tags_input.html" %} + +
        + + + {% match message %}{% when BulkEditTagMessage::Success %} +
        Success!
        + {% when BulkEditTagMessage::Failure with (msg) %} +
        Error: {{ msg }}
        + {% when BulkEditTagMessage::None %}{% endmatch %} +
        +
        +
        + + diff --git a/templates/pages/forbidden.html b/templates/pages/forbidden.html new file mode 100644 index 0000000..994b195 --- /dev/null +++ b/templates/pages/forbidden.html @@ -0,0 +1,14 @@ + + + + Forbidden + {% include "fragments/common_headers.html" %} + + + +
        +

        Forbidden

        +

        The requested action is not allowed.

        +
        + + diff --git a/templates/pages/index.html b/templates/pages/index.html index 407bebc..8425e5e 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -14,6 +14,13 @@

        Search

        {% let tags_value = "" %} {% include "fragments/tags_input.html" %} +
          @@ -34,6 +41,9 @@ Create pool {% if user.is_admin %} +
        • + Bulk edit tag +
        • Settings
        • diff --git a/templates/pages/internal_server_error.html b/templates/pages/internal_server_error.html new file mode 100644 index 0000000..3b7721e --- /dev/null +++ b/templates/pages/internal_server_error.html @@ -0,0 +1,17 @@ + + + + Internal server error + {% include "fragments/common_headers.html" %} + + + +
          +

          Internal server error

          +

          + Something went wrong! Ask your administrator to check the application + logs. +

          +
          + + 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 %} diff --git a/templates/pages/posts.html b/templates/pages/posts.html index e89c4b8..2356019 100644 --- a/templates/pages/posts.html +++ b/templates/pages/posts.html @@ -9,10 +9,18 @@ {% if age_confirmation %}{% include "fragments/age_restricted_check.html" %}{% endif %} +

          Search

          {% let tags_value = tags_text.clone().unwrap_or("".into()) %} {% include "fragments/tags_input.html" %} +
            diff --git a/templates/pages/settings.html b/templates/pages/settings.html index ff79c13..23a764d 100644 --- a/templates/pages/settings.html +++ b/templates/pages/settings.html @@ -20,6 +20,14 @@ value="{{ application_name }}" /> +
            + + +
            +
            + + +
            + + + Unauthorized + {% include "fragments/common_headers.html" %} + + + +
            +

            Unauthorized

            +

            The provided credentials are invalid.

            +
            + + diff --git a/templates/pages/upload.html b/templates/pages/upload.html index f6533a6..e3853d2 100644 --- a/templates/pages/upload.html +++ b/templates/pages/upload.html @@ -13,6 +13,13 @@

            Upload media

            {% let tags_value = "" %} {% include "fragments/tags_input.html" %} +
              < To posts
              -
              -

              Search

              - - -
                - - -
                {% for item in pool_data %} @@ -112,15 +92,19 @@ {% endif %} -
                +

                Tags

                {% if tags.is_empty() %}

                No tags in post. Consider adding some!

                {% else %} -