From 2b6b1f30f4766c891ebdc0245aa6b79a04b6228a Mon Sep 17 00:00:00 2001 From: Bad Manners Date: Wed, 9 Apr 2025 22:31:32 -0300 Subject: [PATCH] Initial version of pools --- README.md | 3 +- .../src/m20250405_000001_create_table.rs | 36 +++ src/auth.rs | 10 +- src/entities/mod.rs | 1 + src/entities/prelude.rs | 1 + src/entities/samey_config.rs | 18 ++ src/entities/samey_pool.rs | 16 ++ src/entities/samey_user.rs | 8 + src/error.rs | 4 +- src/lib.rs | 42 ++- src/query.rs | 47 ++- src/views.rs | 272 +++++++++++++++++- templates/add_post_to_pool.html | 9 + templates/edit_post_details.html | 4 +- templates/index.html | 16 +- templates/pool.html | 65 +++++ templates/pool_posts.html | 25 ++ templates/pools.html | 26 ++ templates/posts.html | 2 +- templates/submit_post_details.html | 2 +- templates/view_post.html | 6 +- 21 files changed, 577 insertions(+), 36 deletions(-) create mode 100644 src/entities/samey_config.rs create mode 100644 templates/add_post_to_pool.html create mode 100644 templates/pool.html create mode 100644 templates/pool_posts.html create mode 100644 templates/pools.html diff --git a/README.md b/README.md index aee8c30..93336e2 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ Sam's small image board. Currently a WIP. ## TODO - [ ] Pools +- [ ] Config - [ ] Video support - [ ] Cleanup/fixup background tasks - [ ] CSS -- [ ] CLI, env vars, logging... +- [ ] Cleanup, CLI, env vars, logging, better errors... ## Running in development diff --git a/migration/src/m20250405_000001_create_table.rs b/migration/src/m20250405_000001_create_table.rs index 97621fd..ff3644e 100644 --- a/migration/src/m20250405_000001_create_table.rs +++ b/migration/src/m20250405_000001_create_table.rs @@ -19,6 +19,18 @@ impl MigrationTrait for Migration { ) .await?; + manager + .create_table( + Table::create() + .table(SameyConfig::Table) + .if_not_exists() + .col(pk_auto(SameyConfig::Id)) + .col(string_uniq(SameyConfig::Key)) + .col(json(SameyConfig::Data)) + .to_owned(), + ) + .await?; + manager .create_table( Table::create() @@ -51,6 +63,15 @@ impl MigrationTrait for Migration { .if_not_exists() .col(pk_auto(SameyPool::Id)) .col(string_len_uniq(SameyPool::Name, 100)) + .col(integer(SameyPool::UploaderId)) + .col(boolean(SameyPool::IsPublic).default(false)) + .foreign_key( + ForeignKeyCreateStatement::new() + .name("fk-samey_pool-samey_user-uploader_id") + .from(SameyPool::Table, SameyPool::UploaderId) + .to(SameyUser::Table, SameyUser::Id) + .on_delete(ForeignKeyAction::Cascade), + ) .to_owned(), ) .await?; @@ -220,6 +241,10 @@ impl MigrationTrait for Migration { .drop_table(Table::drop().table(SameyUser::Table).to_owned()) .await?; + manager + .drop_table(Table::drop().table(SameyConfig::Table).to_owned()) + .await?; + manager .drop_table(Table::drop().table(SameySession::Table).to_owned()) .await?; @@ -238,6 +263,15 @@ enum SameySession { ExpiryDate, } +#[derive(DeriveIden)] +enum SameyConfig { + #[sea_orm(iden = "samey_config")] + Table, + Id, + Key, + Data, +} + #[derive(DeriveIden)] enum SameyUser { #[sea_orm(iden = "samey_user")] @@ -314,6 +348,8 @@ enum SameyPool { Table, Id, Name, + UploaderId, + IsPublic, } #[derive(DeriveIden)] diff --git a/src/auth.rs b/src/auth.rs index 8fd6801..975b075 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -133,7 +133,16 @@ impl SessionStore for SessionStorage { } async fn save(&self, record: &Record) -> session_store::Result<()> { + let session = SameySession::find() + .filter(samey_session::Column::SessionId.eq(record.id.to_string())) + .one(&self.db) + .await + .map_err(|_| session_store::Error::Backend("Failed to find session".into()))? + .ok_or(session_store::Error::Backend( + "No corresponding session found".into(), + ))?; SameySession::update(samey_session::ActiveModel { + id: Set(session.id), data: Set(sea_orm::JsonValue::Object( record .data @@ -144,7 +153,6 @@ impl SessionStore for SessionStorage { expiry_date: Set(record.expiry_date.unix_timestamp()), ..Default::default() }) - .filter(samey_session::Column::SessionId.eq(record.id.to_string())) .exec(&self.db) .await .map_err(|_| session_store::Error::Backend("Failed to update session".into()))?; diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 7c33cad..4d10827 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -2,6 +2,7 @@ pub mod prelude; +pub mod samey_config; pub mod samey_pool; pub mod samey_pool_post; pub mod samey_post; diff --git a/src/entities/prelude.rs b/src/entities/prelude.rs index b85b8eb..a0c8a32 100644 --- a/src/entities/prelude.rs +++ b/src/entities/prelude.rs @@ -1,5 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8 +pub use super::samey_config::Entity as SameyConfig; pub use super::samey_pool::Entity as SameyPool; pub use super::samey_pool_post::Entity as SameyPoolPost; pub use super::samey_post::Entity as SameyPost; diff --git a/src/entities/samey_config.rs b/src/entities/samey_config.rs new file mode 100644 index 0000000..d181deb --- /dev/null +++ b/src/entities/samey_config.rs @@ -0,0 +1,18 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "samey_config")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub key: String, + pub data: Json, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/samey_pool.rs b/src/entities/samey_pool.rs index 9b6663c..81e9783 100644 --- a/src/entities/samey_pool.rs +++ b/src/entities/samey_pool.rs @@ -9,12 +9,22 @@ pub struct Model { pub id: i32, #[sea_orm(unique)] pub name: String, + pub uploader_id: i32, + pub is_public: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm(has_many = "super::samey_pool_post::Entity")] SameyPoolPost, + #[sea_orm( + belongs_to = "super::samey_user::Entity", + from = "Column::UploaderId", + to = "super::samey_user::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + SameyUser, } impl Related for Entity { @@ -23,4 +33,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::SameyUser.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/samey_user.rs b/src/entities/samey_user.rs index e623800..5a6ebb1 100644 --- a/src/entities/samey_user.rs +++ b/src/entities/samey_user.rs @@ -15,10 +15,18 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { + #[sea_orm(has_many = "super::samey_pool::Entity")] + SameyPool, #[sea_orm(has_many = "super::samey_post::Entity")] SameyPost, } +impl Related for Entity { + fn to() -> RelationDef { + Relation::SameyPool.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::SameyPost.def() diff --git a/src/error.rs b/src/error.rs index 0ac152e..d142e3d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -25,6 +25,8 @@ pub enum SameyError { Authentication(String), #[error("Not allowed")] Forbidden, + #[error("Bad request: {0}")] + BadRequest(String), #[error("Internal error: {0}")] Other(String), } @@ -42,7 +44,7 @@ impl IntoResponse for SameyError { | SameyError::Other(_) => { (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong!").into_response() } - SameyError::Multipart(_) => { + SameyError::Multipart(_) | SameyError::BadRequest(_) => { (StatusCode::BAD_REQUEST, "Invalid request").into_response() } SameyError::NotFound => (StatusCode::NOT_FOUND, "Resource not found").into_response(), diff --git a/src/lib.rs b/src/lib.rs index 4c73604..481550e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,7 +7,6 @@ pub(crate) mod views; use std::sync::Arc; -use auth::SessionStorage; use axum::{ Router, extract::DefaultBodyLimit, @@ -20,13 +19,14 @@ use tokio::fs; use tower_http::services::ServeDir; use tower_sessions::SessionManagerLayer; -use crate::auth::Backend; +use crate::auth::{Backend, SessionStorage}; use crate::entities::{prelude::SameyUser, samey_user}; pub use crate::error::SameyError; use crate::views::{ - add_post_source, delete_post, edit_post_details, get_full_media, get_media, index, login, - logout, post_details, posts, posts_page, remove_field, search_tags, select_tag, - submit_post_details, upload, view_post, + add_post_source, add_post_to_pool, change_pool_visibility, create_pool, delete_post, + edit_post_details, get_full_media, get_media, get_pools, get_pools_page, index, login, logout, + post_details, posts, posts_page, remove_field, remove_pool_post, search_tags, select_tag, + submit_post_details, upload, view_pool, view_post, }; pub(crate) const NEGATIVE_PREFIX: &str = "-"; @@ -67,25 +67,39 @@ pub async fn get_router(db: DatabaseConnection, files_dir: &str) -> Result Select { .order_by_asc(samey_tag::Column::Name) } +#[derive(Debug, FromQueryResult)] +pub(crate) struct PoolPost { + pub(crate) id: i32, + pub(crate) thumbnail: String, + pub(crate) rating: String, + pub(crate) pool_post_id: i32, + pub(crate) position: f32, + pub(crate) tags: String, +} + +pub(crate) fn get_posts_in_pool( + pool_id: i32, + user: Option<&User>, +) -> Selector> { + filter_by_user( + SameyPost::find() + .column(samey_post::Column::Id) + .column(samey_post::Column::Thumbnail) + .column(samey_post::Column::Rating) + .column_as(samey_pool_post::Column::Id, "pool_post_id") + .column(samey_pool_post::Column::Position) + .column_as( + Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"), + "tags", + ) + .inner_join(SameyPoolPost) + .inner_join(SameyTagPost) + .join( + sea_orm::JoinType::InnerJoin, + samey_tag_post::Relation::SameyTag.def(), + ) + .filter(samey_pool_post::Column::PoolId.eq(pool_id)), + user, + ) + .group_by(samey_post::Column::Id) + .order_by_asc(samey_pool_post::Column::Position) + .into_model::() +} + pub(crate) fn filter_by_user(query: Select, user: Option<&User>) -> Select { match user { None => query.filter(samey_post::Column::IsPublic.into_simple_expr()), - Some(user) if !user.is_admin => query.filter( + Some(user) if user.is_admin => query, + Some(user) => query.filter( Condition::any() .add(samey_post::Column::IsPublic.into_simple_expr()) .add(samey_post::Column::UploaderId.eq(user.id)), ), - _ => query, } } diff --git a/src/views.rs b/src/views.rs index f1ff19e..8ccca17 100644 --- a/src/views.rs +++ b/src/views.rs @@ -17,7 +17,8 @@ use itertools::Itertools; use migration::{Expr, OnConflict}; use rand::Rng; use sea_orm::{ - ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, + ActiveValue::Set, ColumnTrait, Condition, EntityTrait, FromQueryResult, IntoSimpleExpr, + ModelTrait, PaginatorTrait, QueryFilter, QuerySelect, }; use serde::Deserialize; use tokio::task::spawn_blocking; @@ -26,11 +27,13 @@ use crate::{ AppState, NEGATIVE_PREFIX, RATING_PREFIX, auth::{AuthSession, Credentials, User}, entities::{ - prelude::{SameyPost, SameyPostSource, SameyTag, SameyTagPost}, - samey_post, samey_post_source, samey_tag, samey_tag_post, + prelude::{SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag, SameyTagPost}, + samey_pool, samey_pool_post, samey_post, samey_post_source, samey_tag, samey_tag_post, }, error::SameyError, - query::{PostOverview, filter_by_user, get_tags_for_post, search_posts}, + query::{ + PoolPost, PostOverview, filter_by_user, get_posts_in_pool, get_tags_for_post, search_posts, + }, }; const MAX_THUMBNAIL_DIMENSION: u32 = 192; @@ -195,7 +198,6 @@ pub(crate) async fn upload( thumbnail: Set(thumbnail_file), title: Set(None), description: Set(None), - is_public: Set(false), rating: Set("u".to_owned()), uploaded_at: Set(Utc::now().naive_utc()), parent_id: Set(None), @@ -218,7 +220,7 @@ pub(crate) async fn upload( .exec(&db) .await?; - Ok(Redirect::to(&format!("/view/{}", uploaded_post))) + Ok(Redirect::to(&format!("/post/{}", uploaded_post))) } else { Err(SameyError::Other("Missing parameters for upload".into())) } @@ -425,6 +427,264 @@ pub(crate) async fn posts_page( )) } +// Pool views + +pub(crate) async fn get_pools( + state: State, + auth_session: AuthSession, +) -> Result { + get_pools_page(state, auth_session, Path(1)).await +} + +#[derive(Template)] +#[template(path = "pools.html")] +struct GetPoolsTemplate { + pools: Vec, + page: u32, + page_count: u64, +} + +pub(crate) async fn get_pools_page( + State(AppState { db, .. }): State, + auth_session: AuthSession, + Path(page): Path, +) -> Result { + let query = match auth_session.user { + None => SameyPool::find().filter(samey_pool::Column::IsPublic.into_simple_expr()), + Some(user) if user.is_admin => SameyPool::find(), + Some(user) => SameyPool::find().filter( + Condition::any() + .add(samey_pool::Column::IsPublic.into_simple_expr()) + .add(samey_pool::Column::UploaderId.eq(user.id)), + ), + }; + + let pagination = query.paginate(&db, 25); + let page_count = pagination.num_pages().await?; + + let pools = pagination.fetch_page(page.saturating_sub(1) as u64).await?; + + Ok(Html( + GetPoolsTemplate { + pools, + page, + page_count, + } + .render()?, + )) +} + +#[derive(Debug, Deserialize)] +pub(crate) struct CreatePoolForm { + pool: String, +} + +pub(crate) async fn create_pool( + State(AppState { db, .. }): State, + auth_session: AuthSession, + Form(body): Form, +) -> Result { + let user = match auth_session.user { + Some(user) => user, + None => return Err(SameyError::Forbidden), + }; + + let pool_id = SameyPool::insert(samey_pool::ActiveModel { + name: Set(body.pool), + uploader_id: Set(user.id), + ..Default::default() + }) + .exec(&db) + .await? + .last_insert_id; + + Ok(Redirect::to(&format!("/pool/{}", pool_id))) +} + +#[derive(Template)] +#[template(path = "pool.html")] +struct ViewPoolTemplate { + pool: samey_pool::Model, + posts: Vec, + can_edit: bool, +} + +pub(crate) async fn view_pool( + State(AppState { db, .. }): State, + auth_session: AuthSession, + Path(pool_id): Path, +) -> Result { + let pool_id = pool_id as i32; + 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 !pool.is_public && !can_edit { + return Err(SameyError::NotFound); + } + + let posts = get_posts_in_pool(pool_id, auth_session.user.as_ref()) + .all(&db) + .await?; + + Ok(Html( + ViewPoolTemplate { + pool, + can_edit, + posts, + } + .render()?, + )) +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ChangePoolVisibilityForm { + is_public: Option, +} + +pub(crate) async fn change_pool_visibility( + State(AppState { db, .. }): State, + auth_session: AuthSession, + Path(pool_id): Path, + Form(body): Form, +) -> Result { + let pool_id = pool_id as i32; + let pool = SameyPool::find_by_id(pool_id) + .one(&db) + .await? + .expect("Pool for samey_pool_post must exist"); + + 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::update(samey_pool::ActiveModel { + id: Set(pool.id), + is_public: Set(body.is_public.is_some()), + ..Default::default() + }) + .exec(&db) + .await?; + + Ok("") +} + +#[derive(Debug, Deserialize)] +pub(crate) struct AddPostToPoolForm { + post_id: i32, +} + +#[derive(Debug, FromQueryResult)] +pub(crate) struct PoolWithMaxPosition { + id: i32, + uploader_id: i32, + max_position: Option, +} + +#[derive(Template)] +#[template(path = "add_post_to_pool.html")] +struct AddPostToPoolTemplate { + posts: Vec, + can_edit: bool, +} + +pub(crate) async fn add_post_to_pool( + State(AppState { db, .. }): State, + auth_session: AuthSession, + Path(pool_id): Path, + Form(body): Form, +) -> Result { + let pool = SameyPool::find_by_id(pool_id as i32) + .select_only() + .column(samey_pool::Column::Id) + .column(samey_pool::Column::UploaderId) + .column_as(samey_pool_post::Column::Position.max(), "max_position") + .left_join(SameyPoolPost) + .group_by(samey_pool::Column::Id) + .into_model::() + .one(&db) + .await? + .ok_or(SameyError::NotFound)?; + + let can_edit_pool = match auth_session.user.as_ref() { + None => false, + Some(user) => user.is_admin || pool.uploader_id == user.id, + }; + + if !can_edit_pool { + return Err(SameyError::Forbidden); + } + + let post = filter_by_user( + SameyPost::find_by_id(body.post_id), + auth_session.user.as_ref(), + ) + .one(&db) + .await? + .ok_or(SameyError::NotFound)?; + + SameyPoolPost::insert(samey_pool_post::ActiveModel { + pool_id: Set(pool.id), + post_id: Set(post.id), + position: Set(pool.max_position.unwrap_or(-1.0).floor() + 1.0), + ..Default::default() + }) + .exec(&db) + .await?; + + let posts = get_posts_in_pool(pool.id, auth_session.user.as_ref()) + .all(&db) + .await?; + + Ok(Html( + AddPostToPoolTemplate { + posts, + can_edit: true, + } + .render()?, + )) +} + +pub(crate) async fn remove_pool_post( + State(AppState { db, .. }): State, + auth_session: AuthSession, + Path(pool_post_id): Path, +) -> Result { + let pool_post_id = pool_post_id as i32; + let pool_post = SameyPoolPost::find_by_id(pool_post_id) + .one(&db) + .await? + .ok_or(SameyError::NotFound)?; + let pool = SameyPool::find_by_id(pool_post.pool_id) + .one(&db) + .await? + .expect("Pool for samey_pool_post must exist"); + + 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); + } + + pool_post.delete(&db).await?; + + Ok("") +} + // Single post views #[derive(Template)] diff --git a/templates/add_post_to_pool.html b/templates/add_post_to_pool.html new file mode 100644 index 0000000..de4f838 --- /dev/null +++ b/templates/add_post_to_pool.html @@ -0,0 +1,9 @@ +{% include "pool_posts.html" %} + diff --git a/templates/edit_post_details.html b/templates/edit_post_details.html index 0ce7fc0..8a9988c 100644 --- a/templates/edit_post_details.html +++ b/templates/edit_post_details.html @@ -25,7 +25,7 @@
- +
@@ -48,7 +48,7 @@
- +
diff --git a/templates/index.html b/templates/index.html index 0e60297..d59142f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,6 +8,7 @@
+

Samey

Search

@@ -27,6 +28,10 @@
+ {% if let Some(user) = user %}

Upload media

@@ -37,8 +42,9 @@ id="upload-tags" name="tags" hx-post="/search_tags" - hx-trigger="input changed delay:400ms" + hx-trigger="input changed" hx-target="next .tags-autocomplete" + hx-vals="js:{selection_end: event.target.selectionEnd}" hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);" />
    @@ -54,7 +60,13 @@

    Create pool

    - +
    diff --git a/templates/pool.html b/templates/pool.html new file mode 100644 index 0000000..b12d71d --- /dev/null +++ b/templates/pool.html @@ -0,0 +1,65 @@ + + + + + + + + Pool - {{ pool.name }} - Samey + + + + + {% if let Some(post) = posts.first() %} + + + {% endif %} + + +
    +

    Pool - {{ pool.name }}

    +
    +
    +

    Posts

    + {% include "pool_posts.html" %} +
    + {% if can_edit %} +
    +
    +
    + + + +
    +
    +
    + + +
    +
    + {% endif %} + + diff --git a/templates/pool_posts.html b/templates/pool_posts.html new file mode 100644 index 0000000..446a96f --- /dev/null +++ b/templates/pool_posts.html @@ -0,0 +1,25 @@ +
    + {% if posts.is_empty() %} + No posts in pool. + {% else %} + + {% endif %} +
    diff --git a/templates/pools.html b/templates/pools.html new file mode 100644 index 0000000..ce499f5 --- /dev/null +++ b/templates/pools.html @@ -0,0 +1,26 @@ + + + + + + + Pools - Samey + + +
    +

    Viewing pools

    + {% if pools.is_empty() %} +
    No pools found!
    + {% else %} + +
    Page {{ page }} of {{ page_count }}
    + {% endif %} +
    + + diff --git a/templates/posts.html b/templates/posts.html index f35e07e..2dc2348 100644 --- a/templates/posts.html +++ b/templates/posts.html @@ -43,7 +43,7 @@ {% for post in posts %}
  • diff --git a/templates/submit_post_details.html b/templates/submit_post_details.html index df37412..3951f88 100644 --- a/templates/submit_post_details.html +++ b/templates/submit_post_details.html @@ -1,7 +1,7 @@ {% include "post_details.html" %} {% if let Some(parent_post) = parent_post %}