Initial version of pools

This commit is contained in:
Bad Manners 2025-04-09 22:31:32 -03:00
parent fe7edb93ad
commit 2b6b1f30f4
21 changed files with 577 additions and 36 deletions

View file

@ -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()))?;

View file

@ -2,6 +2,7 @@
pub mod prelude;
pub mod samey_config;
pub mod samey_pool;
pub mod samey_pool_post;
pub mod samey_post;

View file

@ -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;

View file

@ -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 {}

View file

@ -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<super::samey_pool_post::Entity> for Entity {
@ -23,4 +33,10 @@ impl Related<super::samey_pool_post::Entity> for Entity {
}
}
impl Related<super::samey_user::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyUser.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -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<super::samey_pool::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPool.def()
}
}
impl Related<super::samey_post::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPost.def()

View file

@ -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(),

View file

@ -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<Route
let auth_layer = AuthManagerLayerBuilder::new(Backend::new(db), session_layer).build();
Ok(Router::new()
// Auth routes
.route("/login", post(login))
.route("/logout", get(logout))
// Tags routes
.route("/search_tags", post(search_tags))
.route("/select_tag", post(select_tag))
// Post routes
.route(
"/upload",
post(upload).layer(DefaultBodyLimit::max(100_000_000)),
)
.route("/search_tags", post(search_tags))
.route("/select_tag", post(select_tag))
.route("/posts", get(posts))
.route("/posts/{page}", get(posts_page))
.route("/view/{post_id}", get(view_post))
.route("/post/{post_id}", delete(delete_post))
.route("/post/{post_id}", get(view_post).delete(delete_post))
.route("/post_details/{post_id}/edit", get(edit_post_details))
.route("/post_details/{post_id}", get(post_details))
.route("/post_details/{post_id}", put(submit_post_details))
.route(
"/post_details/{post_id}",
get(post_details).put(submit_post_details),
)
.route("/post_source", post(add_post_source))
.route("/remove", delete(remove_field))
.route("/media/{post_id}/full", get(get_full_media))
.route("/media/{post_id}", get(get_media))
// Pool routes
.route("/pools", get(get_pools))
.route("/pools/{page}", get(get_pools_page))
.route("/pool", post(create_pool))
.route("/pool/{pool_id}", get(view_pool))
.route("/pool/{pool_id}/public", put(change_pool_visibility))
.route("/pool/{pool_id}/post", post(add_post_to_pool))
.route("/pool_post/{pool_post_id}", delete(remove_pool_post))
// Search routes
.route("/posts", get(posts))
.route("/posts/{page}", get(posts_page))
// Other routes
.route("/remove", delete(remove_field))
.route("/", get(index))
.with_state(state)
.nest_service("/files", ServeDir::new(files_dir))

View file

@ -10,8 +10,8 @@ use crate::{
NEGATIVE_PREFIX, RATING_PREFIX,
auth::User,
entities::{
prelude::{SameyPost, SameyTag, SameyTagPost},
samey_post, samey_tag, samey_tag_post,
prelude::{SameyPoolPost, SameyPost, SameyTag, SameyTagPost},
samey_pool_post, samey_post, samey_tag, samey_tag_post,
},
};
@ -146,14 +146,53 @@ pub(crate) fn get_tags_for_post(post_id: i32) -> Select<SameyTag> {
.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<SelectModel<PoolPost>> {
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::<PoolPost>()
}
pub(crate) fn filter_by_user(query: Select<SameyPost>, user: Option<&User>) -> Select<SameyPost> {
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,
}
}

View file

@ -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<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
get_pools_page(state, auth_session, Path(1)).await
}
#[derive(Template)]
#[template(path = "pools.html")]
struct GetPoolsTemplate {
pools: Vec<samey_pool::Model>,
page: u32,
page_count: u64,
}
pub(crate) async fn get_pools_page(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(page): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
Form(body): Form<CreatePoolForm>,
) -> Result<impl IntoResponse, SameyError> {
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<PoolPost>,
can_edit: bool,
}
pub(crate) async fn view_pool(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
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<String>,
}
pub(crate) async fn change_pool_visibility(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<u32>,
Form(body): Form<ChangePoolVisibilityForm>,
) -> Result<impl IntoResponse, SameyError> {
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<f32>,
}
#[derive(Template)]
#[template(path = "add_post_to_pool.html")]
struct AddPostToPoolTemplate {
posts: Vec<PoolPost>,
can_edit: bool,
}
pub(crate) async fn add_post_to_pool(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<u32>,
Form(body): Form<AddPostToPoolForm>,
) -> Result<impl IntoResponse, SameyError> {
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::<PoolWithMaxPosition>()
.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<AppState>,
auth_session: AuthSession,
Path(pool_post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
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)]