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

8
src/entities/mod.rs Normal file
View file

@ -0,0 +1,8 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
pub mod prelude;
pub mod samey_post;
pub mod samey_post_source;
pub mod samey_tag;
pub mod samey_tag_post;

6
src/entities/prelude.rs Normal file
View file

@ -0,0 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
pub use super::samey_post::Entity as SameyPost;
pub use super::samey_post_source::Entity as SameyPostSource;
pub use super::samey_tag::Entity as SameyTag;
pub use super::samey_tag_post::Entity as SameyTagPost;

View file

@ -0,0 +1,52 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "samey_post")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub media: String,
pub width: i32,
pub height: i32,
pub thumbnail: String,
pub title: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub is_public: bool,
#[sea_orm(column_type = "custom(\"enum_text\")")]
pub rating: String,
pub uploaded_at: DateTime,
pub parent_id: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "Entity",
from = "Column::ParentId",
to = "Column::Id",
on_update = "NoAction",
on_delete = "SetNull"
)]
SelfRef,
#[sea_orm(has_many = "super::samey_post_source::Entity")]
SameyPostSource,
#[sea_orm(has_many = "super::samey_tag_post::Entity")]
SameyTagPost,
}
impl Related<super::samey_post_source::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPostSource.def()
}
}
impl Related<super::samey_tag_post::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyTagPost.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,32 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "samey_post_source")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub url: String,
pub post_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::samey_post::Entity",
from = "Column::PostId",
to = "super::samey_post::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
SameyPost,
}
impl Related<super::samey_post::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPost.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

27
src/entities/samey_tag.rs Normal file
View file

@ -0,0 +1,27 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "samey_tag")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub normalized_name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::samey_tag_post::Entity")]
SameyTagPost,
}
impl Related<super::samey_tag_post::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyTagPost.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,46 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "samey_tag_post")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub tag_id: i32,
pub post_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::samey_post::Entity",
from = "Column::PostId",
to = "super::samey_post::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
SameyPost,
#[sea_orm(
belongs_to = "super::samey_tag::Entity",
from = "Column::TagId",
to = "super::samey_tag::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
SameyTag,
}
impl Related<super::samey_post::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyPost.def()
}
}
impl Related<super::samey_tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::SameyTag.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

47
src/error.rs Normal file
View file

@ -0,0 +1,47 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
#[derive(Debug, thiserror::Error)]
pub enum SameyError {
#[error("Integer conversion error: {0}")]
IntConversion(#[from] std::num::TryFromIntError),
#[error("IO error: {0}")]
IO(#[from] std::io::Error),
#[error("Task error: {0}")]
Join(#[from] tokio::task::JoinError),
#[error("Template render error: {0}")]
Render(#[from] askama::Error),
#[error("Database error: {0}")]
Database(#[from] sea_orm::error::DbErr),
#[error("File streaming error: {0}")]
Multipart(#[from] axum::extract::multipart::MultipartError),
#[error("Image error: {0}")]
Image(#[from] image::ImageError),
#[error("Internal error: {0}")]
Other(String),
#[error("Not found")]
NotFound,
}
impl IntoResponse for SameyError {
fn into_response(self) -> Response {
println!("Server error - {}", &self);
match &self {
SameyError::IntConversion(_)
| SameyError::IO(_)
| SameyError::Join(_)
| SameyError::Render(_)
| SameyError::Database(_)
| SameyError::Image(_)
| SameyError::Other(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong!").into_response()
}
SameyError::Multipart(_) => {
(StatusCode::BAD_REQUEST, "Invalid request").into_response()
}
SameyError::NotFound => (StatusCode::NOT_FOUND, "Resource not found").into_response(),
}
}
}

58
src/lib.rs Normal file
View file

@ -0,0 +1,58 @@
pub(crate) mod entities;
pub(crate) mod error;
pub(crate) mod query;
pub(crate) mod rating;
pub(crate) mod views;
use std::sync::Arc;
use axum::{
Router,
extract::DefaultBodyLimit,
routing::{delete, get, post, put},
};
use sea_orm::DatabaseConnection;
use tokio::fs;
use tower_http::services::ServeDir;
pub use crate::error::SameyError;
use crate::views::{
add_post_source, delete_post, edit_post_details, get_full_media, get_media, index,
post_details, posts, posts_page, remove_field, search_tags, select_tag, submit_post_details,
upload, view_post,
};
#[derive(Clone)]
pub(crate) struct AppState {
files_dir: Arc<String>,
db: DatabaseConnection,
}
pub async fn get_router(db: DatabaseConnection, files_dir: &str) -> Result<Router, SameyError> {
let state = AppState {
files_dir: Arc::new(files_dir.into()),
db,
};
fs::create_dir_all(files_dir).await?;
Ok(Router::new()
.route(
"/upload",
post(upload).layer(DefaultBodyLimit::max(100_000_000)),
)
.route("/search_tags", post(search_tags))
.route("/select_tag/{new_tag}", post(select_tag))
.route("/posts/{page}", get(posts_page))
.route("/posts", get(posts))
.route("/view/{post_id}", get(view_post))
.route("/post/{post_id}", delete(delete_post))
.route("/post_details/{post_id}/edit", get(edit_post_details))
.route("/post_details/{post_id}", get(post_details))
.route("/post_details/{post_id}", put(submit_post_details))
.route("/post_source", post(add_post_source))
.route("/remove", delete(remove_field))
.route("/media/{post_id}/full", get(get_full_media))
.route("/media/{post_id}", get(get_media))
.route("/", get(index))
.with_state(state)
.nest_service("/files", ServeDir::new(files_dir)))
}

21
src/main.rs Normal file
View file

@ -0,0 +1,21 @@
use migration::{Migrator, MigratorTrait};
use samey::get_router;
use sea_orm::Database;
#[tokio::main]
async fn main() {
let db = Database::connect("sqlite:db.sqlite3?mode=rwc")
.await
.expect("Unable to connect to database");
Migrator::up(&db, None)
.await
.expect("Unable to apply migrations");
let app = get_router(db, "files")
.await
.expect("Unable to start router");
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("Unable to listen to port");
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}

90
src/query.rs Normal file
View file

@ -0,0 +1,90 @@
use std::collections::HashSet;
use migration::{Expr, Query};
use sea_orm::{
ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
Select, SelectModel, Selector,
};
use crate::entities::{
prelude::{SameyPost, SameyTag, SameyTagPost},
samey_post, samey_tag, samey_tag_post,
};
#[derive(Debug, FromQueryResult)]
pub(crate) struct SearchPost {
pub(crate) id: i32,
pub(crate) thumbnail: String,
pub(crate) tags: String,
}
pub(crate) fn search_posts(tags: Option<&Vec<&str>>) -> Selector<SelectModel<SearchPost>> {
let tags: HashSet<String> = match tags {
Some(tags) if !tags.is_empty() => tags.iter().map(|&tag| tag.to_lowercase()).collect(),
_ => HashSet::new(),
};
if tags.is_empty() {
let query = SameyPost::find()
.select_only()
.column(samey_post::Column::Id)
.column(samey_post::Column::Thumbnail)
.column_as(
Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"),
"tags",
)
.inner_join(SameyTagPost)
.join(
sea_orm::JoinType::InnerJoin,
samey_tag_post::Relation::SameyTag.def(),
)
.group_by(samey_post::Column::Id)
.order_by_desc(samey_post::Column::Id);
// println!("{}", &query.build(sea_orm::DatabaseBackend::Sqlite).sql);
return query.into_model::<SearchPost>();
};
let tags_count = tags.len() as u32;
let subquery = Query::select()
.column((SameyPost, samey_post::Column::Id))
.from(SameyPost)
.inner_join(
SameyTagPost,
Expr::col((SameyPost, samey_post::Column::Id))
.equals((SameyTagPost, samey_tag_post::Column::PostId)),
)
.inner_join(
SameyTag,
Expr::col((SameyTagPost, samey_tag_post::Column::TagId))
.equals((SameyTag, samey_tag::Column::Id)),
)
.and_where(samey_tag::Column::NormalizedName.is_in(tags))
.group_by_col((SameyPost, samey_post::Column::Id))
.and_having(samey_tag::Column::Id.count().eq(tags_count))
.to_owned();
let query = SameyPost::find()
.select_only()
.column(samey_post::Column::Id)
.column(samey_post::Column::Thumbnail)
.column_as(
Expr::cust("GROUP_CONCAT(\"samey_tag\".\"name\", ' ')"),
"tags",
)
.inner_join(SameyTagPost)
.join(
sea_orm::JoinType::InnerJoin,
samey_tag_post::Relation::SameyTag.def(),
)
.filter(samey_post::Column::Id.in_subquery(subquery))
.group_by(samey_post::Column::Id)
.order_by_desc(samey_post::Column::Id);
// println!("{}", &query.build(sea_orm::DatabaseBackend::Sqlite).sql);
query.into_model::<SearchPost>()
}
pub(crate) fn get_tags_for_post(post_id: i32) -> Select<SameyTag> {
SameyTag::find()
.inner_join(SameyTagPost)
.filter(samey_tag_post::Column::PostId.eq(post_id))
.order_by_asc(samey_tag::Column::Name)
}

31
src/rating.rs Normal file
View file

@ -0,0 +1,31 @@
use std::fmt::Display;
#[derive(PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Rating {
Unrated,
Safe,
Questionable,
Explicit,
}
impl From<String> for Rating {
fn from(value: String) -> Self {
match value.as_ref() {
"s" => Self::Safe,
"q" => Self::Questionable,
"e" => Self::Explicit,
_ => Self::Unrated,
}
}
}
impl Display for Rating {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Rating::Unrated => f.write_str("Unrated"),
Rating::Safe => f.write_str("Safe"),
Rating::Questionable => f.write_str("Questionable"),
Rating::Explicit => f.write_str("Explicit"),
}
}
}

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"))
}