samey/src/views.rs

1895 lines
57 KiB
Rust

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<askama::filters::Safe<String>> {
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<User>,
}
pub(crate) async fn index(
State(AppState { app_config, .. }): State<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
Query(query): Query<PostsQuery>,
) -> Result<impl IntoResponse, SameyError> {
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::<Vec<_>>());
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<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<Credentials>,
) -> Result<impl IntoResponse, SameyError> {
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<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<Self, Self::Err> {
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<AppState>,
auth_session: AuthSession,
mut multipart: Multipart,
) -> Result<impl IntoResponse, SameyError> {
let user = match auth_session.user {
Some(user) => user,
None => return Err(SameyError::Forbidden),
};
let mut upload_tags: Option<Vec<samey_tag::Model>> = None;
let mut source_file: Option<String> = None;
let mut media_type: Option<&'static str> = None;
let mut width: Option<NonZero<i32>> = None;
let mut height: Option<NonZero<i32>> = None;
let mut thumbnail_file: Option<String> = None;
let mut thumbnail_width: Option<NonZero<i32>> = None;
let mut thumbnail_height: Option<NonZero<i32>> = 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<String> = 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<String> =
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<SearchTag>,
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<AppState>,
Form(body): Form<SearchTagsForm>,
) -> Result<impl IntoResponse, SameyError> {
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<SelectTagForm>,
) -> Result<impl IntoResponse, SameyError> {
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<Vec<&'a str>>,
tags_text: Option<String>,
posts: Vec<PostOverview>,
page: u32,
page_count: u64,
}
#[derive(Debug, Deserialize)]
pub(crate) struct PostsQuery {
tags: Option<String>,
}
pub(crate) async fn posts(
state: State<AppState>,
auth_session: AuthSession,
query: Query<PostsQuery>,
) -> Result<impl IntoResponse, SameyError> {
posts_page(state, auth_session, query, Path(1)).await
}
pub(crate) async fn posts_page(
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Query(query): Query<PostsQuery>,
Path(page): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
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::<Vec<_>>());
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<String> = post.tags.map(|tags| {
let mut tags_vec = tags.split_ascii_whitespace().collect::<Vec<&str>>();
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<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<samey_pool::Model>,
page: u32,
page_count: u64,
}
pub(crate) async fn get_pools_page(
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Path(page): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
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<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 = "pages/pool.html")]
struct ViewPoolTemplate {
application_name: String,
age_confirmation: bool,
pool: samey_pool::Model,
posts: Vec<PoolPost>,
can_edit: bool,
}
pub(crate) async fn view_pool(
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<i32>,
Form(body): Form<ChangePoolNameForm>,
) -> Result<impl IntoResponse, SameyError> {
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<String>,
}
pub(crate) async fn change_pool_visibility(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<i32>,
Form(body): Form<ChangePoolVisibilityForm>,
) -> Result<impl IntoResponse, SameyError> {
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<f32>,
}
#[derive(Template)]
#[template(path = "fragments/add_post_to_pool.html")]
struct AddPostToPoolTemplate {
pool: PoolWithMaxPosition,
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<i32>,
Form(body): Form<AddPostToPoolForm>,
) -> Result<impl IntoResponse, SameyError> {
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::<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_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<AppState>,
auth_session: AuthSession,
Path(pool_post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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<PoolPost>,
can_edit: bool,
}
pub(crate) async fn sort_pool(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<i32>,
Form(body): Form<SortPoolForm>,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
Path(pool_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
Form(body): Form<EditTagForm>,
) -> Result<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
) -> Result<impl IntoResponse, SameyError> {
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<dyn Any>> = config
.iter()
.filter_map(|row| match row.key.as_str() {
key if key == APPLICATION_NAME_KEY => row
.data
.as_str()
.map::<(&str, Box<dyn Any>), _>(|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<bool>,
}
pub(crate) async fn update_settings(
State(AppState {
db,
app_config,
files_dir,
..
}): State<AppState>,
auth_session: AuthSession,
Form(body): Form<UpdateSettingsForm>,
) -> Result<impl IntoResponse, SameyError> {
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::<i32>() {
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<PostPoolData>,
tags: Vec<samey_tag::Model>,
tags_text: Option<String>,
tags_post: String,
sources: Vec<samey_post_source::Model>,
can_edit: bool,
parent_post: Option<PostOverview>,
children_posts: Vec<PostOverview>,
}
pub(crate) async fn view_post_page(
State(AppState { db, app_config, .. }): State<AppState>,
auth_session: AuthSession,
Query(query): Query<PostsQuery>,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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<samey_post_source::Model>,
can_edit: bool,
}
pub(crate) async fn post_details(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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<String>,
rating: String,
#[serde(rename = "source")]
sources: Option<Vec<String>>,
tags: String,
parent_post: String,
}
#[derive(Template)]
#[template(path = "fragments/submit_post_details.html")]
struct SubmitPostDetailsTemplate {
post: samey_post::Model,
parent_post: Option<PostOverview>,
sources: Vec<samey_post_source::Model>,
tags: Vec<samey_tag::Model>,
tags_text: String,
can_edit: bool,
}
pub(crate) async fn submit_post_details(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<i32>,
Form(body): Form<SubmitPostDetailsForm>,
) -> Result<impl IntoResponse, SameyError> {
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<String> = body.tags.split_whitespace().map(String::from).collect();
let normalized_tags: HashSet<String> = 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<String>,
}
#[derive(Template)]
#[template(path = "fragments/edit_post_details.html")]
struct EditDetailsTemplate {
post: samey_post::Model,
sources: Vec<EditPostSource>,
tags: String,
}
pub(crate) async fn edit_post_details(
State(AppState { db, .. }): State<AppState>,
auth_session: AuthSession,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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::<String>()
.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<impl IntoResponse, SameyError> {
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<AppState>,
auth_session: AuthSession,
Path(post_id): Path<i32>,
) -> Result<impl IntoResponse, SameyError> {
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("/"))
}