diff --git a/README.md b/README.md index 97c1823..fe36f08 100644 --- a/README.md +++ b/README.md @@ -6,31 +6,36 @@ Sam's small image board. Still very much an early WIP. +### Features + +- Image and video posts. +- Tagging with autocompletion. +- Post pools. +- RSS feeds. + ### Known issues -- [ ] No way to close tag autocompletion on mobile +- No way to close tag autocompletion on mobile ### Roadmap - [ ] Favicon from post -- [ ] Bulk edit tag -- [ ] RSS feed -- [ ] Logging -- [ ] Improved error handling -- [ ] Caching +- [ ] Delete pools +- [ ] Logging and improved error handling - [ ] Lossless compression -- [ ] User management -- [ ] Cleanup/fixup background tasks +- [ ] Caching - [ ] Text media - [ ] Improve CSS +- [ ] Garbage collection background tasks (tags, pools) +- [ ] User management - [ ] Migrate to Cot...? -## Prerequisites +## Running + +### Prerequisites - `ffmpeg` and `ffprobe` -## Running - ### Development ```bash diff --git a/src/lib.rs b/src/lib.rs index 180f7f0..4e36efd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -129,6 +129,8 @@ pub async fn get_router( .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 diff --git a/src/main.rs b/src/main.rs index 8c11604..8c8c2e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,8 @@ +use std::{ + net::{IpAddr, Ipv6Addr}, + path::PathBuf, +}; + use clap::{Parser, Subcommand}; use migration::{Migrator, MigratorTrait}; use samey::{create_user, get_router}; @@ -5,50 +10,80 @@ 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 }) => { + + 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/views.rs b/src/views.rs index b05d1af..f1378ea 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1148,6 +1148,139 @@ pub(crate) async fn sort_pool( )) } +// 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 = migration::Query::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)] @@ -1450,6 +1583,7 @@ struct SubmitPostDetailsTemplate { parent_post: Option, sources: Vec, tags: Vec, + tags_text: String, can_edit: bool, } @@ -1580,6 +1714,13 @@ 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)) @@ -1591,6 +1732,7 @@ pub(crate) async fn submit_post_details( post, sources, tags, + tags_text, parent_post, can_edit: true, } diff --git a/templates/fragments/submit_post_details.html b/templates/fragments/submit_post_details.html index 8a7d39c..d20788f 100644 --- a/templates/fragments/submit_post_details.html +++ b/templates/fragments/submit_post_details.html @@ -26,7 +26,7 @@ parent_post %} 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/index.html b/templates/pages/index.html index a69c25f..8425e5e 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -41,6 +41,9 @@ Create pool {% if user.is_admin %} +
  • + Bulk edit tag +
  • Settings
  • diff --git a/templates/pages/view_post.html b/templates/pages/view_post.html index 17268d1..8da0c97 100644 --- a/templates/pages/view_post.html +++ b/templates/pages/view_post.html @@ -100,7 +100,11 @@