use std::{ any::Any, collections::{HashMap, HashSet}, fs::OpenOptions, io::{BufReader, Seek, Write}, mem, num::NonZero, str::FromStr, }; use askama::Template; use axum::{ extract::{Multipart, Path, Query, State}, response::{Html, IntoResponse, Redirect}, }; use axum_extra::extract::Form; use chrono::Utc; use image::{GenericImageView, ImageFormat, ImageReader}; use itertools::Itertools; use rand::Rng; use samey_migration::{Expr, OnConflict, Query as MigrationQuery}; use sea_orm::{ ActiveValue::Set, ColumnTrait, Condition, EntityTrait, FromQueryResult, IntoSimpleExpr, ModelTrait, PaginatorTrait, QueryFilter, QuerySelect, }; use serde::Deserialize; use tokio::{task::spawn_blocking, try_join}; use crate::{ AppState, NEGATIVE_PREFIX, RATING_PREFIX, auth::{AuthSession, Credentials, User}, config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY, BASE_URL_KEY}, entities::{ prelude::{ SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag, SameyTagPost, }, samey_config, samey_pool, samey_pool_post, samey_post, samey_post_source, samey_tag, samey_tag_post, }, error::SameyError, query::{ 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, }, video::{generate_thumbnail, get_dimensions_for_video}, }; const MAX_THUMBNAIL_DIMENSION: u32 = 192; // Filters mod filters { pub(crate) fn markdown( s: impl std::fmt::Display, ) -> askama::Result> { let s = s.to_string(); let parser = pulldown_cmark::Parser::new(&s); let mut output = String::new(); pulldown_cmark::html::push_html(&mut output, parser); Ok(askama::filters::Safe(output)) } } // Index view #[derive(Template)] #[template(path = "pages/index.html")] struct IndexTemplate { application_name: String, age_confirmation: bool, user: Option, } pub(crate) async fn index( State(AppState { app_config, .. }): State, auth_session: AuthSession, ) -> Result { 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( IndexTemplate { application_name, age_confirmation, user: auth_session.user, } .render()?, )) } // 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)] #[template(path = "pages/login.html")] struct LoginPageTemplate { application_name: String, age_confirmation: bool, } pub(crate) async fn login_page( State(AppState { app_config, .. }): State, auth_session: AuthSession, ) -> Result { if auth_session.user.is_some() { return Ok(Redirect::to("/").into_response()); } 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( LoginPageTemplate { application_name, age_confirmation, } .render()?, ) .into_response()) } pub(crate) async fn login( mut auth_session: AuthSession, Form(credentials): Form, ) -> Result { let user = match auth_session.authenticate(credentials).await? { Some(user) => user, None => return Err(SameyError::Authentication("Invalid credentials".into())), }; auth_session.login(&user).await?; Ok(Redirect::to("/")) } pub(crate) async fn logout(mut auth_session: AuthSession) -> Result { auth_session.logout().await?; Ok(Redirect::to("/")) } // Post upload views #[derive(Template)] #[template(path = "pages/upload.html")] struct UploadPageTemplate { application_name: String, age_confirmation: bool, } pub(crate) async fn upload_page( State(AppState { app_config, .. }): State, auth_session: AuthSession, ) -> Result { if auth_session.user.is_none() { 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( UploadPageTemplate { application_name, age_confirmation, } .render()?, ) .into_response()) } enum Format { Video(&'static str), Image(ImageFormat), } impl Format { fn media_type(&self) -> &'static str { match self { Format::Video(_) => "video", Format::Image(_) => "image", } } } impl FromStr for Format { type Err = SameyError; fn from_str(content_type: &str) -> Result { match content_type { "video/mp4" => Ok(Self::Video(".mp4")), "video/webm" => Ok(Self::Video(".webm")), "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::BadRequest( format!("Unknown content type: {}", content_type), ))?, )), } } } pub(crate) async fn upload( State(AppState { db, files_dir, .. }): State, auth_session: AuthSession, mut multipart: Multipart, ) -> Result { let user = match auth_session.user { Some(user) => user, None => return Err(SameyError::Forbidden), }; let mut upload_tags: Option> = None; let mut source_file: Option = None; let mut media_type: Option<&'static str> = None; let mut width: Option> = None; let mut height: Option> = None; let mut thumbnail_file: Option = None; let mut thumbnail_width: Option> = None; let mut thumbnail_height: Option> = None; let base_path = files_dir.as_ref(); // Read multipart form data while let Some(mut field) = multipart.next_field().await.unwrap() { match field.name().unwrap() { "tags" => { if let Ok(tags) = field.text().await { let tags: HashSet = tags .split_whitespace() .filter_map(|tag| { if tag.starts_with(NEGATIVE_PREFIX) || tag.starts_with(RATING_PREFIX) { None } else { Some(String::from(tag)) } }) .collect(); let normalized_tags: HashSet = tags.iter().map(|tag| tag.to_lowercase()).collect(); if tags.is_empty() { upload_tags = Some(vec![]); } else { SameyTag::insert_many(tags.into_iter().map(|tag| samey_tag::ActiveModel { normalized_name: Set(tag.to_lowercase()), name: Set(tag), ..Default::default() })) .on_conflict( OnConflict::column(samey_tag::Column::NormalizedName) .do_nothing() .to_owned(), ) .exec_without_returning(&db) .await?; upload_tags = Some( SameyTag::find() .filter(samey_tag::Column::NormalizedName.is_in(normalized_tags)) .all(&db) .await?, ); } } } "media-file" => { let content_type = field .content_type() .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()); let thumbnail_format = ImageFormat::Png; let (file_name, thumbnail_file_name) = { let mut rng = rand::rng(); let mut file_name: String = (0..8) .map(|_| rng.sample(rand::distr::Alphanumeric) as char) .collect(); let thumbnail_file_name = format!( "thumb-{}.{}", file_name, thumbnail_format.extensions_str()[0] ); file_name.push_str(video_format); (file_name, thumbnail_file_name) }; let file_path = base_path.join(&file_name); let mut file = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(true) .open(&file_path)?; while let Some(chunk) = field.chunk().await? { file.write_all(&chunk)?; } let file_path_2 = file_path.to_string_lossy().into_owned(); let thumbnail_path = base_path.join(&thumbnail_file_name); let jh_thumbnail = spawn_blocking(move || { generate_thumbnail( &file_path_2, &thumbnail_path.to_string_lossy(), MAX_THUMBNAIL_DIMENSION, )?; let mut image = ImageReader::new(BufReader::new( OpenOptions::new().read(true).open(thumbnail_path)?, )); image.set_format(thumbnail_format); Ok(image.into_dimensions()?) }); let file_path_2 = file_path.to_string_lossy().into_owned(); let jh_video = spawn_blocking(move || get_dimensions_for_video(&file_path_2)); let (dim_thumbnail, dim_video) = match try_join!(jh_thumbnail, jh_video)? { (Ok(dim_thumbnail), Ok(dim_video)) => (dim_thumbnail, dim_video), (Err(err), _) | (_, Err(err)) => return Err(err), }; width = NonZero::new(dim_video.0.try_into()?); height = NonZero::new(dim_video.1.try_into()?); thumbnail_width = NonZero::new(dim_thumbnail.0.try_into()?); thumbnail_height = NonZero::new(dim_thumbnail.1.try_into()?); source_file = Some(file_name); thumbnail_file = Some(thumbnail_file_name); } format @ Format::Image(image_format) => { media_type = Some(format.media_type()); let file_name = { let mut rng = rand::rng(); let mut file_name: String = (0..8) .map(|_| rng.sample(rand::distr::Alphanumeric) as char) .collect(); file_name.push('.'); file_name.push_str(image_format.extensions_str()[0]); file_name }; let thumbnail_file_name = format!("thumb-{}", file_name); let file_path = base_path.join(&file_name); let mut file = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(true) .open(&file_path)?; while let Some(chunk) = field.chunk().await? { file.write_all(&chunk)?; } let base_path_2 = base_path.to_owned(); let thumbnail_path = base_path_2.join(&thumbnail_file_name); let (w, h, tw, th) = spawn_blocking(move || -> Result<_, SameyError> { file.seek(std::io::SeekFrom::Start(0))?; let mut image = ImageReader::new(BufReader::new(file)); image.set_format(image_format); let image = image.decode()?; let (w, h) = image.dimensions(); let width = NonZero::new(w.try_into()?); let height = NonZero::new(h.try_into()?); let thumbnail = image.resize( MAX_THUMBNAIL_DIMENSION, MAX_THUMBNAIL_DIMENSION, image::imageops::FilterType::CatmullRom, ); thumbnail.save(thumbnail_path)?; let (tw, th) = image.dimensions(); let thumbnail_width = NonZero::new(tw.try_into()?); let thumbnail_height = NonZero::new(th.try_into()?); Ok((width, height, thumbnail_width, thumbnail_height)) }) .await??; width = w; height = h; thumbnail_width = tw; thumbnail_height = th; source_file = Some(file_name); thumbnail_file = Some(thumbnail_file_name); } } } _ => (), } } if let ( Some(upload_tags), Some(source_file), Some(media_type), Some(thumbnail_file), Some(width), Some(height), Some(thumbnail_width), Some(thumbnail_height), ) = ( upload_tags, source_file, media_type, thumbnail_file, width.map(|w| w.get()), height.map(|h| h.get()), thumbnail_width.map(|w| w.get()), thumbnail_height.map(|h| h.get()), ) { let uploaded_post = SameyPost::insert(samey_post::ActiveModel { uploader_id: Set(user.id), media: Set(source_file), media_type: Set(media_type.into()), width: Set(width), height: Set(height), thumbnail: Set(thumbnail_file), thumbnail_width: Set(thumbnail_width), thumbnail_height: Set(thumbnail_height), title: Set(None), description: Set(None), rating: Set("u".to_owned()), uploaded_at: Set(Utc::now().naive_utc()), parent_id: Set(None), ..Default::default() }) .exec(&db) .await? .last_insert_id; // Add tags to post if !upload_tags.is_empty() { SameyTagPost::insert_many(upload_tags.into_iter().map(|tag| { samey_tag_post::ActiveModel { post_id: Set(uploaded_post), tag_id: Set(tag.id), ..Default::default() } })) .exec(&db) .await?; } Ok(Redirect::to(&format!("/post/{}", uploaded_post))) } else { Err(SameyError::BadRequest( "Missing parameters for upload".into(), )) } } // Search fields views struct SearchTag { name: String, value: String, } #[derive(Template)] #[template(path = "fragments/search_tags.html")] struct SearchTagsTemplate { tags: Vec, selection_end: usize, } #[derive(Debug, Deserialize)] pub(crate) struct SearchTagsForm { tags: String, selection_end: usize, } pub(crate) async fn search_tags( State(AppState { db, .. }): State, Form(body): Form, ) -> Result { let tags = match body.tags[..body.selection_end].split(' ').next_back() { Some(mut tag) => { tag = tag.trim(); if tag.is_empty() { vec![] } else if let Some(stripped_tag) = tag.strip_prefix(NEGATIVE_PREFIX) { if stripped_tag.starts_with(RATING_PREFIX) { [ format!("{}u", RATING_PREFIX), format!("{}s", RATING_PREFIX), format!("{}q", RATING_PREFIX), format!("{}e", RATING_PREFIX), ] .into_iter() .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(?, '%')", stripped_tag.to_lowercase(), )) .limit(10) .all(&db) .await? .into_iter() .map(|tag| SearchTag { value: format!("-{}", &tag.name), name: tag.name, }) .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() } else { SameyTag::find() .filter(Expr::cust_with_expr( "LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')", tag.to_lowercase(), )) .limit(10) .all(&db) .await? .into_iter() .map(|tag| SearchTag { value: tag.name.clone(), name: tag.name, }) .collect() } } _ => vec![], }; Ok(Html( SearchTagsTemplate { tags, selection_end: body.selection_end, } .render()?, )) } #[derive(Template)] #[template(path = "fragments/select_tag.html")] struct SelectTagTemplate { tags_value: String, } #[derive(Debug, Deserialize)] pub(crate) struct SelectTagForm { tags: String, new_tag: String, selection_end: usize, } pub(crate) async fn select_tag( Form(body): Form, ) -> Result { let mut tags = String::new(); for (tag, _) in body.tags[..body.selection_end].split(' ').tuple_windows() { if !tag.is_empty() { if !tags.is_empty() { tags.push(' '); } tags.push_str(tag); } } if !tags.is_empty() { tags.push(' '); } tags.push_str(&body.new_tag); for tag in body.tags[body.selection_end..].split(' ') { if !tag.is_empty() { tags.push(' '); tags.push_str(tag); } } tags.push(' '); Ok(Html(SelectTagTemplate { tags_value: tags }.render()?)) } // Post list views #[derive(Template)] #[template(path = "pages/posts.html")] struct PostsTemplate<'a> { application_name: String, age_confirmation: bool, tags: Option>, tags_text: Option, posts: Vec, page: u32, page_count: u64, } #[derive(Debug, Deserialize)] pub(crate) struct PostsQuery { tags: Option, } pub(crate) async fn posts( state: State, auth_session: AuthSession, query: Query, ) -> Result { posts_page(state, auth_session, query, Path(1)).await } pub(crate) async fn posts_page( State(AppState { db, app_config, .. }): State, auth_session: AuthSession, Query(query): Query, Path(page): Path, ) -> Result { 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 tags = query .tags .as_ref() .map(|tags| tags.split_whitespace().collect::>()); let pagination = search_posts(tags.as_ref(), auth_session.user.as_ref()).paginate(&db, 50); let page_count = pagination.num_pages().await?; let posts = pagination.fetch_page(page.saturating_sub(1) as u64).await?; let posts = posts .into_iter() .map(|post| { let tags: Option = post.tags.map(|tags| { let mut tags_vec = tags.split_ascii_whitespace().collect::>(); tags_vec.sort(); tags_vec.into_iter().join(" ") }); PostOverview { tags, ..post } }) .collect(); Ok(Html( PostsTemplate { application_name, age_confirmation, tags_text: tags.as_ref().map(|tags| tags.iter().join(" ")), tags, posts, page, page_count, } .render()?, )) } // Pool views #[derive(Template)] #[template(path = "pages/create_pool.html")] struct CreatePoolPageTemplate { application_name: String, age_confirmation: bool, } pub(crate) async fn create_pool_page( State(AppState { app_config, .. }): State, auth_session: AuthSession, ) -> Result { if auth_session.user.is_none() { 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( CreatePoolPageTemplate { application_name, age_confirmation, } .render()?, ) .into_response()) } 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 = "pages/pools.html")] struct GetPoolsTemplate { application_name: String, age_confirmation: bool, pools: Vec, page: u32, page_count: u64, } pub(crate) async fn get_pools_page( State(AppState { db, app_config, .. }): State, auth_session: AuthSession, Path(page): Path, ) -> Result { 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 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 { application_name, age_confirmation, 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 = "pages/pool.html")] struct ViewPoolTemplate { application_name: String, age_confirmation: bool, pool: samey_pool::Model, posts: Vec, can_edit: bool, } pub(crate) async fn view_pool( State(AppState { db, app_config, .. }): State, auth_session: AuthSession, Path(pool_id): Path, ) -> Result { 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 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 { application_name, age_confirmation, pool, can_edit, posts, } .render()?, )) } #[derive(Debug, Deserialize)] pub(crate) struct ChangePoolNameForm { pool_name: String, } #[derive(Template)] #[template(path = "fragments/change_pool_name.html")] struct ChangePoolNameTemplate { pool_name: String, } pub(crate) async fn change_pool_name( State(AppState { db, .. }): State, auth_session: AuthSession, Path(pool_id): Path, Form(body): Form, ) -> 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); } if body.pool_name.trim().is_empty() { return Err(SameyError::BadRequest("Pool name cannot be empty".into())); } SameyPool::update(samey_pool::ActiveModel { id: Set(pool.id), name: Set(body.pool_name.clone()), ..Default::default() }) .exec(&db) .await?; Ok(Html( ChangePoolNameTemplate { pool_name: body.pool_name, } .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 = 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::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)] struct PoolWithMaxPosition { id: i32, uploader_id: i32, max_position: Option, } #[derive(Template)] #[template(path = "fragments/add_post_to_pool.html")] struct AddPostToPoolTemplate { pool: PoolWithMaxPosition, 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) .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_posts_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(0.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 { pool, 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 = 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("") } #[derive(Debug, Deserialize)] pub(crate) struct SortPoolForm { old_index: usize, new_index: usize, } #[derive(Template)] #[template(path = "fragments/pool_posts.html")] struct PoolPostsTemplate { pool: samey_pool::Model, posts: Vec, can_edit: bool, } pub(crate) async fn sort_pool( State(AppState { db, .. }): State, auth_session: AuthSession, Path(pool_id): Path, Form(body): Form, ) -> 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); } if body.old_index != body.new_index { let posts = get_posts_in_pool(pool_id, auth_session.user.as_ref()) .all(&db) .await?; let changed_post = posts.get(body.old_index).ok_or(SameyError::NotFound)?; let min_index = if body.new_index < body.old_index { body.new_index.checked_sub(1) } else { Some(body.new_index) }; 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 { Some(body.new_index + 1) }; let min = min_index.map(|index| posts[index].position).unwrap_or(0.0); let max = max_index .map(|index| posts[index].position) .unwrap_or_else(|| posts.last().map(|post| post.position).unwrap_or(min) + 2.0); SameyPoolPost::update(samey_pool_post::ActiveModel { id: Set(changed_post.pool_post_id), position: Set((min + max) / 2.0), ..Default::default() }) .exec(&db) .await?; } let posts = get_posts_in_pool(pool_id, auth_session.user.as_ref()) .all(&db) .await?; Ok(Html( PoolPostsTemplate { pool, posts, can_edit: true, } .render()?, )) } 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, } pub(crate) async fn settings( State(AppState { db, 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 base_url = app_config.base_url.clone(); let age_confirmation = app_config.age_confirmation; drop(app_config); let config = SameyConfig::find().all(&db).await?; let values: HashMap<&str, Box> = config .iter() .filter_map(|row| match row.key.as_str() { key if key == APPLICATION_NAME_KEY => row .data .as_str() .map::<(&str, Box), _>(|data| (&row.key, Box::new(data.to_owned()))), _ => None, }) .collect(); Ok(Html( SettingsTemplate { application_name, base_url, age_confirmation, } .render_with_values(&values)?, )) } #[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, files_dir, .. }): State, auth_session: AuthSession, Form(body): Form, ) -> Result { if auth_session.user.is_none_or(|user| !user.is_admin) { return Err(SameyError::Forbidden); } let mut configs = vec![]; let application_name = body.application_name.trim(); if !application_name.is_empty() { let _ = mem::replace( &mut app_config.write().await.application_name, application_name.into(), ); configs.push(samey_config::ActiveModel { key: Set(APPLICATION_NAME_KEY.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, age_confirmation, ); configs.push(samey_config::ActiveModel { key: Set(AGE_CONFIRMATION_KEY.into()), data: Set(age_confirmation.into()), ..Default::default() }); if !configs.is_empty() { SameyConfig::insert_many(configs) .on_conflict( OnConflict::column(samey_config::Column::Key) .update_column(samey_config::Column::Data) .to_owned(), ) .exec(&db) .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("/")) } // Single post views #[derive(Template)] #[template(path = "pages/view_post.html")] struct ViewPostPageTemplate { application_name: String, age_confirmation: bool, post: samey_post::Model, pool_data: Vec, tags: Vec, tags_text: Option, tags_post: String, sources: Vec, can_edit: bool, parent_post: Option, children_posts: Vec, } pub(crate) async fn view_post_page( State(AppState { db, app_config, .. }): State, auth_session: AuthSession, Query(query): Query, Path(post_id): Path, ) -> Result { 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 post = SameyPost::find_by_id(post_id) .one(&db) .await? .ok_or(SameyError::NotFound)?; let can_edit = match auth_session.user.as_ref() { None => false, Some(user) => user.is_admin || post.uploader_id == user.id, }; if !post.is_public && !can_edit { return Err(SameyError::NotFound); } let tags = get_tags_for_post(post_id).all(&db).await?; let tags_post = tags.iter().map(|tag| &tag.name).join(" "); let sources = SameyPostSource::find() .filter(samey_post_source::Column::PostId.eq(post_id)) .all(&db) .await?; let parent_post = if let Some(parent_id) = post.parent_id { match filter_posts_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref()) .one(&db) .await? { 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) .await? .iter() .map(|tag| &tag.name) .join(" "), ), rating: parent_post.rating, media_type: parent_post.media_type, }), None => None, } } else { None }; let children_posts_models = filter_posts_by_user( SameyPost::find().filter(samey_post::Column::ParentId.eq(post_id)), auth_session.user.as_ref(), ) .all(&db) .await?; let mut children_posts = Vec::with_capacity(children_posts_models.capacity()); for child_post in children_posts_models.into_iter() { 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) .await? .iter() .map(|tag| &tag.name) .join(" "), ), rating: child_post.rating, media_type: child_post.media_type, }); } let pool_data = get_pool_data_for_post(&db, post_id, auth_session.user.as_ref()).await?; Ok(Html( ViewPostPageTemplate { application_name, age_confirmation, post, pool_data, tags, tags_text: query.tags, tags_post, sources, can_edit, parent_post, children_posts, } .render()?, )) } #[derive(Template)] #[template(path = "fragments/post_details.html")] struct PostDetailsTemplate { post: samey_post::Model, sources: Vec, can_edit: bool, } pub(crate) async fn post_details( State(AppState { db, .. }): State, auth_session: AuthSession, Path(post_id): Path, ) -> Result { let sources = SameyPostSource::find() .filter(samey_post_source::Column::PostId.eq(post_id)) .all(&db) .await?; let post = SameyPost::find_by_id(post_id) .one(&db) .await? .ok_or(SameyError::NotFound)?; let can_edit = match auth_session.user { None => false, Some(user) => user.is_admin || post.uploader_id == user.id, }; if !post.is_public && !can_edit { return Err(SameyError::NotFound); } Ok(Html( PostDetailsTemplate { post, sources, can_edit, } .render()?, )) } #[derive(Debug, Deserialize)] pub(crate) struct SubmitPostDetailsForm { title: String, description: String, is_public: Option, rating: String, #[serde(rename = "source")] sources: Option>, tags: String, parent_post: String, } #[derive(Template)] #[template(path = "fragments/submit_post_details.html")] struct SubmitPostDetailsTemplate { post: samey_post::Model, parent_post: Option, sources: Vec, tags: Vec, tags_text: String, can_edit: bool, } pub(crate) async fn submit_post_details( State(AppState { db, .. }): State, auth_session: AuthSession, Path(post_id): Path, Form(body): Form, ) -> Result { let post = SameyPost::find_by_id(post_id) .one(&db) .await? .ok_or(SameyError::NotFound)?; match auth_session.user.as_ref() { None => return Err(SameyError::Forbidden), Some(user) => { if !user.is_admin && post.uploader_id != user.id { return Err(SameyError::Forbidden); } } } let title = match body.title.trim() { "" => None, title => Some(title.to_owned()), }; let description = match body.description.trim() { "" => None, description => Some(description.to_owned()), }; 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? { 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) .await? .iter() .map(|tag| &tag.name) .join(" "), ), rating: parent_post.rating, media_type: parent_post.media_type, }), None => None, } } else { None }; let is_public = body.is_public.is_some(); let post = SameyPost::update(samey_post::ActiveModel { id: Set(post_id), title: Set(title), description: Set(description), is_public: Set(is_public), rating: Set(body.rating), parent_id: Set(parent_post.as_ref().map(|post| post.id)), ..Default::default() }) .exec(&db) .await?; // TODO: Improve this to not delete sources without necessity SameyPostSource::delete_many() .filter(samey_post_source::Column::PostId.eq(post_id)) .exec(&db) .await?; // TODO: Improve this to not recreate existing sources (see above) if let Some(sources) = body.sources { let sources: Vec<_> = sources .into_iter() .filter(|source| !source.is_empty()) .map(|source| samey_post_source::ActiveModel { url: Set(source), post_id: Set(post_id), ..Default::default() }) .collect(); if !sources.is_empty() { SameyPostSource::insert_many(sources).exec(&db).await?; } }; let tags: HashSet = body.tags.split_whitespace().map(String::from).collect(); let normalized_tags: HashSet = tags.iter().map(|tag| tag.to_lowercase()).collect(); // TODO: Improve this to not delete tag-post entries without necessity SameyTagPost::delete_many() .filter(samey_tag_post::Column::PostId.eq(post_id)) .exec(&db) .await?; let tags = if tags.is_empty() { vec![] } else { // TODO: Improve this to not recreate existing tag-post entries (see above) SameyTag::insert_many(tags.into_iter().map(|tag| samey_tag::ActiveModel { normalized_name: Set(tag.to_lowercase()), name: Set(tag), ..Default::default() })) .on_conflict( OnConflict::column(samey_tag::Column::NormalizedName) .do_nothing() .to_owned(), ) .exec_without_returning(&db) .await?; let mut upload_tags = SameyTag::find() .filter(samey_tag::Column::NormalizedName.is_in(normalized_tags)) .all(&db) .await?; SameyTagPost::insert_many(upload_tags.iter().map(|tag| samey_tag_post::ActiveModel { post_id: Set(post_id), tag_id: Set(tag.id), ..Default::default() })) .exec(&db) .await?; 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, } .render()?, )) } struct EditPostSource { url: Option, } #[derive(Template)] #[template(path = "fragments/edit_post_details.html")] struct EditDetailsTemplate { post: samey_post::Model, sources: Vec, tags: String, } pub(crate) async fn edit_post_details( State(AppState { db, .. }): State, auth_session: AuthSession, Path(post_id): Path, ) -> Result { let post = SameyPost::find_by_id(post_id) .one(&db) .await? .ok_or(SameyError::NotFound)?; match auth_session.user { None => return Err(SameyError::Forbidden), Some(user) => { if !user.is_admin && post.uploader_id != user.id { return Err(SameyError::Forbidden); } } } let sources = SameyPostSource::find() .filter(samey_post_source::Column::PostId.eq(post_id)) .all(&db) .await? .into_iter() .map(|source| EditPostSource { url: Some(source.url), }) .collect(); let tags = get_tags_for_post(post_id) .select_only() .column(samey_tag::Column::Name) .into_tuple::() .all(&db) .await? .join(" "); Ok(Html( EditDetailsTemplate { post, sources, tags, } .render()?, )) } #[derive(Template)] #[template(path = "fragments/post_source.html")] struct AddPostSourceTemplate { source: EditPostSource, } pub(crate) async fn add_post_source() -> Result { Ok(Html( AddPostSourceTemplate { source: EditPostSource { url: None }, } .render()?, )) } pub(crate) async fn remove_field() -> impl IntoResponse { "" } pub(crate) async fn delete_post( State(AppState { db, files_dir, .. }): State, auth_session: AuthSession, Path(post_id): Path, ) -> Result { let post = SameyPost::find_by_id(post_id) .one(&db) .await? .ok_or(SameyError::NotFound)?; match auth_session.user { None => return Err(SameyError::Forbidden), Some(user) => { if !user.is_admin && post.uploader_id != user.id { return Err(SameyError::Forbidden); } } } SameyPost::delete_by_id(post.id).exec(&db).await?; tokio::spawn(async move { 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("/")) }