Initial commit
This commit is contained in:
commit
2722c7d40a
36 changed files with 6266 additions and 0 deletions
8
src/entities/mod.rs
Normal file
8
src/entities/mod.rs
Normal 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
6
src/entities/prelude.rs
Normal 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;
|
||||
52
src/entities/samey_post.rs
Normal file
52
src/entities/samey_post.rs
Normal 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 {}
|
||||
32
src/entities/samey_post_source.rs
Normal file
32
src/entities/samey_post_source.rs
Normal 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
27
src/entities/samey_tag.rs
Normal 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 {}
|
||||
46
src/entities/samey_tag_post.rs
Normal file
46
src/entities/samey_tag_post.rs
Normal 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
47
src/error.rs
Normal 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
58
src/lib.rs
Normal 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
21
src/main.rs
Normal 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
90
src/query.rs
Normal 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
31
src/rating.rs
Normal 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
602
src/views.rs
Normal 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"))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue