Initial commit

This commit is contained in:
Bad Manners 2025-04-06 19:14:10 -03:00
commit 2722c7d40a
36 changed files with 6266 additions and 0 deletions

602
src/views.rs Normal file
View file

@ -0,0 +1,602 @@
use std::{
collections::HashSet,
fs::OpenOptions,
io::{BufReader, Seek, Write},
num::NonZero,
};
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 migration::{Expr, OnConflict};
use rand::Rng;
use sea_orm::{
ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect,
};
use serde::Deserialize;
use tokio::task::spawn_blocking;
use crate::{
AppState,
entities::{
prelude::{SameyPost, SameyPostSource, SameyTag, SameyTagPost},
samey_post, samey_post_source, samey_tag, samey_tag_post,
},
error::SameyError,
query::{SearchPost, get_tags_for_post, search_posts},
};
const MAX_THUMBNAIL_DIMENSION: u32 = 192;
// Index view
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {}
pub(crate) async fn index() -> Result<impl IntoResponse, SameyError> {
Ok(Html(IndexTemplate {}.render()?))
}
// Post upload view
pub(crate) async fn upload(
State(AppState { db, files_dir }): State<AppState>,
mut multipart: Multipart,
) -> Result<impl IntoResponse, SameyError> {
let mut upload_tags: Option<Vec<samey_tag::Model>> = None;
let mut source_file: Option<String> = None;
let mut thumbnail_file: Option<String> = None;
let mut width: Option<NonZero<i32>> = None;
let mut height: Option<NonZero<i32>> = None;
let base_path = std::path::Path::new(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().map(String::from).collect();
let normalized_tags: HashSet<String> =
tags.iter().map(|tag| tag.to_lowercase()).collect();
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::Other("Missing content type".into()))?;
let format = ImageFormat::from_mime_type(content_type).ok_or(SameyError::Other(
format!("Unknown content type: {}", content_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(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)
.open(&file_path)?;
while let Some(chunk) = field.chunk().await? {
file.write_all(&chunk)?;
}
let base_path_2 = base_path.to_owned();
let (w, h, thumbnail_file_name) =
spawn_blocking(move || -> Result<_, SameyError> {
file.seek(std::io::SeekFrom::Start(0))?;
let mut image = ImageReader::new(BufReader::new(file));
image.set_format(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(base_path_2.join(&thumbnail_file_name))?;
Ok((width, height, thumbnail_file_name))
})
.await??;
width = w;
height = h;
source_file = Some(file_name);
thumbnail_file = Some(thumbnail_file_name);
}
_ => (),
}
}
if let (Some(upload_tags), Some(source_file), Some(thumbnail_file), Some(width), Some(height)) = (
upload_tags,
source_file,
thumbnail_file,
width.map(|w| w.get()),
height.map(|h| h.get()),
) {
let uploaded_post = SameyPost::insert(samey_post::ActiveModel {
media: Set(source_file),
width: Set(width),
height: Set(height),
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),
..Default::default()
})
.exec(&db)
.await?
.last_insert_id;
// Add tags to post
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!("/view/{}", uploaded_post)))
} else {
Err(SameyError::Other("Missing parameters for upload".into()))
}
}
// Search fields views
#[derive(Template)]
#[template(path = "search_tags.html")]
struct SearchTagsTemplate {
tags: Vec<samey_tag::Model>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct SearchTagsForm {
tags: String,
}
pub(crate) async fn search_tags(
State(AppState { db, .. }): State<AppState>,
Form(body): Form<SearchTagsForm>,
) -> Result<impl IntoResponse, SameyError> {
let tags = match body.tags.split(' ').last() {
Some(tag) if !tag.is_empty() => {
SameyTag::find()
.filter(Expr::cust_with_expr(
"LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')",
tag.to_lowercase(),
))
.all(&db)
.await?
}
_ => vec![],
};
Ok(Html(SearchTagsTemplate { tags }.render()?))
}
#[derive(Template)]
#[template(path = "select_tag.html")]
struct SelectTagTemplate {
tags: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct SelectTagForm {
tags: String,
}
pub(crate) async fn select_tag(
Path(new_tag): Path<String>,
Form(body): Form<SelectTagForm>,
) -> Result<impl IntoResponse, SameyError> {
let mut tags = String::new();
for (tag, _) in body.tags.split_whitespace().tuple_windows() {
if !tags.is_empty() {
tags.push(' ');
}
tags.push_str(tag);
}
if !tags.is_empty() {
tags.push(' ');
}
tags.push_str(&new_tag);
tags.push(' ');
Ok(Html(SelectTagTemplate { tags }.render()?))
}
// Post list views
#[derive(Template)]
#[template(path = "posts.html")]
struct PostsTemplate<'a> {
tags: Option<Vec<&'a str>>,
tags_text: Option<String>,
posts: Vec<SearchPost>,
page: u32,
page_count: u64,
}
#[derive(Debug, Deserialize)]
pub(crate) struct PostsQuery {
tags: Option<String>,
}
pub(crate) async fn posts(
state: State<AppState>,
query: Query<PostsQuery>,
) -> Result<impl IntoResponse, SameyError> {
posts_page(state, query, Path(1)).await
}
pub(crate) async fn posts_page(
State(AppState { db, .. }): State<AppState>,
Query(query): Query<PostsQuery>,
Path(page): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
let tags = query
.tags
.as_ref()
.map(|tags| tags.split_whitespace().collect::<Vec<_>>());
let pagination = search_posts(tags.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 mut tags_vec: Vec<_> = post.tags.split_ascii_whitespace().collect();
tags_vec.sort();
SearchPost {
tags: tags_vec.into_iter().join(" "),
..post
}
})
.collect();
Ok(Html(
PostsTemplate {
tags_text: tags.as_ref().map(|tags| tags.iter().join(" ")),
tags,
posts,
page,
page_count,
}
.render()?,
))
}
// Single post views
#[derive(Template)]
#[template(path = "view_post.html")]
struct ViewPostTemplate {
post: samey_post::Model,
tags: Vec<samey_tag::Model>,
sources: Vec<samey_post_source::Model>,
}
pub(crate) async fn view_post(
State(AppState { db, .. }): State<AppState>,
Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
let tags = get_tags_for_post(post_id as i32).all(&db).await?;
let sources = SameyPostSource::find()
.filter(samey_post_source::Column::PostId.eq(post_id))
.all(&db)
.await?;
let post = SameyPost::find_by_id(post_id as i32)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
Ok(Html(
ViewPostTemplate {
post,
tags,
sources,
}
.render()?,
))
}
#[derive(Template)]
#[template(path = "post_details.html")]
struct PostDetailsTemplate {
post: samey_post::Model,
sources: Vec<samey_post_source::Model>,
}
pub(crate) async fn post_details(
State(AppState { db, .. }): State<AppState>,
Path(post_id): Path<u32>,
) -> 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 as i32)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
Ok(Html(PostDetailsTemplate { post, sources }.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,
}
#[derive(Template)]
#[template(path = "submit_post_details.html")]
struct SubmitPostDetailsTemplate {
post: samey_post::Model,
sources: Vec<samey_post_source::Model>,
tags: Vec<samey_tag::Model>,
}
pub(crate) async fn submit_post_details(
State(AppState { db, .. }): State<AppState>,
Path(post_id): Path<u32>,
Form(body): Form<SubmitPostDetailsForm>,
) -> Result<impl IntoResponse, SameyError> {
let post_id = post_id as i32;
let title = match body.title.trim() {
title if title.is_empty() => None,
title => Some(title.to_owned()),
};
let description = match body.description.trim() {
description if description.is_empty() => None,
description => Some(description.to_owned()),
};
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),
..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?;
// 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));
let sources = SameyPostSource::find()
.filter(samey_post_source::Column::PostId.eq(post_id))
.all(&db)
.await?;
Ok(Html(
SubmitPostDetailsTemplate {
post,
sources,
tags: upload_tags,
}
.render()?,
))
}
struct EditPostSource {
url: Option<String>,
}
#[derive(Template)]
#[template(path = "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>,
Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
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 as i32)
.select_only()
.column(samey_tag::Column::Name)
.into_tuple::<String>()
.all(&db)
.await?
.join(" ");
let post = SameyPost::find_by_id(post_id as i32)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
Ok(Html(
EditDetailsTemplate {
post,
sources,
tags,
}
.render()?,
))
}
#[derive(Template)]
#[template(path = "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 {
""
}
#[derive(Template)]
#[template(path = "get_media.html")]
struct GetMediaTemplate {
post: samey_post::Model,
}
pub(crate) async fn get_media(
State(AppState { db, .. }): State<AppState>,
Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
let post = SameyPost::find_by_id(post_id as i32)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
Ok(Html(GetMediaTemplate { post }.render()?))
}
#[derive(Template)]
#[template(path = "get_full_media.html")]
struct GetFullMediaTemplate {
post: samey_post::Model,
}
pub(crate) async fn get_full_media(
State(AppState { db, .. }): State<AppState>,
Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
let post = SameyPost::find_by_id(post_id as i32)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
Ok(Html(GetFullMediaTemplate { post }.render()?))
}
pub(crate) async fn delete_post(
State(AppState { db, files_dir }): State<AppState>,
Path(post_id): Path<u32>,
) -> Result<impl IntoResponse, SameyError> {
let post = SameyPost::find_by_id(post_id as i32)
.one(&db)
.await?
.ok_or(SameyError::NotFound)?;
SameyPost::delete_by_id(post.id).exec(&db).await?;
tokio::spawn(async move {
let base_path = std::path::Path::new(files_dir.as_ref());
let _ = std::fs::remove_file(base_path.join(post.media));
let _ = std::fs::remove_file(base_path.join(post.thumbnail));
});
Ok(Redirect::to("/posts/1"))
}