Initial commit
This commit is contained in:
commit
2722c7d40a
36 changed files with 6266 additions and 0 deletions
1
.env
Normal file
1
.env
Normal file
|
|
@ -0,0 +1 @@
|
|||
DATABASE_URL="sqlite:db.sqlite3?mode=rwc"
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
/db.sqlite3*
|
||||
/files
|
||||
4570
Cargo.lock
generated
Normal file
4570
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
34
Cargo.toml
Normal file
34
Cargo.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
[package]
|
||||
name = "samey"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
authors = ["Bad Manners <me@badmanners.xyz>"]
|
||||
readme = "README.md"
|
||||
|
||||
[workspace]
|
||||
members = [".", "migration"]
|
||||
|
||||
[dependencies]
|
||||
askama = "0.13.0"
|
||||
axum = { version = "0.8.3", features = ["multipart", "macros"] }
|
||||
axum-extra = { version = "0.10.1", features = ["form"] }
|
||||
axum-login = "0.17.0"
|
||||
chrono = "0.4.40"
|
||||
futures-util = "0.3.31"
|
||||
image = "0.25.6"
|
||||
itertools = "0.14.0"
|
||||
migration = { path = "migration" }
|
||||
password-auth = "1.0.0"
|
||||
rand = "0.9.0"
|
||||
sea-orm = { version = "1.1.8", features = [
|
||||
"sqlx-sqlite",
|
||||
"runtime-tokio-rustls",
|
||||
"macros",
|
||||
"with-chrono",
|
||||
] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.44.1", features = ["full"] }
|
||||
tower-http = { version = "0.6.2", features = ["fs"] }
|
||||
25
LICENSE
Normal file
25
LICENSE
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
Copyright (c) 2025 Bad Manners
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
18
README.md
Normal file
18
README.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Samey
|
||||
|
||||
Sam's small image board. Currently a WIP.
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Authentication
|
||||
- [ ] Improve filters
|
||||
- [ ] Pools
|
||||
- [ ] Video support
|
||||
- [ ] CSS
|
||||
- [ ] CLI, env vars, logging...
|
||||
|
||||
## Running in development
|
||||
|
||||
```bash
|
||||
bacon serve
|
||||
```
|
||||
6
bacon.toml
Normal file
6
bacon.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[jobs.serve]
|
||||
command = ["cargo", "run"]
|
||||
background = false
|
||||
need_stdout = true
|
||||
on_change_strategy = "kill_then_restart"
|
||||
watch = ["templates"]
|
||||
8
justfile
Normal file
8
justfile
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
default:
|
||||
just --list
|
||||
|
||||
migrate:
|
||||
sea-orm-cli migrate up
|
||||
|
||||
entities:
|
||||
sea-orm-cli generate entity -o src/entities
|
||||
16
migration/Cargo.toml
Normal file
16
migration/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "migration"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
||||
|
||||
[dependencies.sea-orm-migration]
|
||||
version = "1.1.8"
|
||||
features = ["sqlx-sqlite", "runtime-tokio-rustls"]
|
||||
41
migration/README.md
Normal file
41
migration/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Running Migrator CLI
|
||||
|
||||
- Generate a new migration file
|
||||
```sh
|
||||
cargo run -- generate MIGRATION_NAME
|
||||
```
|
||||
- Apply all pending migrations
|
||||
```sh
|
||||
cargo run
|
||||
```
|
||||
```sh
|
||||
cargo run -- up
|
||||
```
|
||||
- Apply first 10 pending migrations
|
||||
```sh
|
||||
cargo run -- up -n 10
|
||||
```
|
||||
- Rollback last applied migrations
|
||||
```sh
|
||||
cargo run -- down
|
||||
```
|
||||
- Rollback last 10 applied migrations
|
||||
```sh
|
||||
cargo run -- down -n 10
|
||||
```
|
||||
- Drop all tables from the database, then reapply all migrations
|
||||
```sh
|
||||
cargo run -- fresh
|
||||
```
|
||||
- Rollback all applied migrations, then reapply all migrations
|
||||
```sh
|
||||
cargo run -- refresh
|
||||
```
|
||||
- Rollback all applied migrations
|
||||
```sh
|
||||
cargo run -- reset
|
||||
```
|
||||
- Check the status of all migrations
|
||||
```sh
|
||||
cargo run -- status
|
||||
```
|
||||
12
migration/src/lib.rs
Normal file
12
migration/src/lib.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20250405_000001_create_table;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![Box::new(m20250405_000001_create_table::Migration)]
|
||||
}
|
||||
}
|
||||
191
migration/src/m20250405_000001_create_table.rs
Normal file
191
migration/src/m20250405_000001_create_table.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(SameyPost::Table)
|
||||
.if_not_exists()
|
||||
.col(pk_auto(SameyPost::Id))
|
||||
.col(string_len(SameyPost::Media, 255))
|
||||
.col(integer(SameyPost::Width))
|
||||
.col(integer(SameyPost::Height))
|
||||
.col(string_len(SameyPost::Thumbnail, 255))
|
||||
.col(string_len_null(SameyPost::Title, 100))
|
||||
.col(text_null(SameyPost::Description))
|
||||
.col(boolean(SameyPost::IsPublic))
|
||||
.col(enumeration(
|
||||
SameyPost::Rating,
|
||||
Rating::Enum,
|
||||
[
|
||||
Rating::Unrated,
|
||||
Rating::Safe,
|
||||
Rating::Questionable,
|
||||
Rating::Explicit,
|
||||
],
|
||||
))
|
||||
.col(date_time(SameyPost::UploadedAt))
|
||||
.col(integer_null(SameyPost::ParentId))
|
||||
.foreign_key(
|
||||
ForeignKeyCreateStatement::new()
|
||||
.name("fk-samey_post-samey_post-parent")
|
||||
.from(SameyPost::Table, SameyPost::ParentId)
|
||||
.to(SameyPost::Table, SameyPost::Id)
|
||||
.on_delete(ForeignKeyAction::SetNull),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(SameyPostSource::Table)
|
||||
.if_not_exists()
|
||||
.col(pk_auto(SameyPostSource::Id))
|
||||
.col(string_len(SameyPostSource::Url, 200))
|
||||
.col(integer(SameyPostSource::PostId))
|
||||
.foreign_key(
|
||||
ForeignKeyCreateStatement::new()
|
||||
.name("fk-samey_post_source-samey_post")
|
||||
.from(SameyPostSource::Table, SameyPostSource::PostId)
|
||||
.to(SameyPost::Table, SameyPostSource::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(SameyTag::Table)
|
||||
.if_not_exists()
|
||||
.col(pk_auto(SameyTag::Id))
|
||||
.col(string_len(SameyTag::Name, 100))
|
||||
.col(string_len_uniq(SameyTag::NormalizedName, 100))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(SameyTagPost::Table)
|
||||
.if_not_exists()
|
||||
.col(pk_auto(SameyTagPost::Id))
|
||||
.col(integer(SameyTagPost::TagId))
|
||||
.col(integer(SameyTagPost::PostId))
|
||||
.foreign_key(
|
||||
ForeignKeyCreateStatement::new()
|
||||
.name("fk-samey_tag_post-samey_tag")
|
||||
.from(SameyTagPost::Table, SameyTagPost::TagId)
|
||||
.to(SameyTag::Table, SameyTag::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKeyCreateStatement::new()
|
||||
.name("fk-samey_tag_post-samey_post")
|
||||
.from(SameyTagPost::Table, SameyTagPost::PostId)
|
||||
.to(SameyPost::Table, SameyPost::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.index(
|
||||
Index::create()
|
||||
.unique()
|
||||
.col(SameyTagPost::PostId)
|
||||
.col(SameyTagPost::TagId),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// Replace the sample below with your own migration scripts
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(SameyTagPost::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(SameyTag::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(SameyPostSource::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(SameyPost::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum SameyPost {
|
||||
#[sea_orm(iden = "samey_post")]
|
||||
Table,
|
||||
Id,
|
||||
Media,
|
||||
Width,
|
||||
Height,
|
||||
Thumbnail,
|
||||
Title,
|
||||
Description,
|
||||
IsPublic,
|
||||
Rating,
|
||||
UploadedAt,
|
||||
ParentId,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
#[sea_orm(enum_name = "rating")]
|
||||
pub enum Rating {
|
||||
#[sea_orm(iden = "rating")]
|
||||
Enum,
|
||||
#[sea_orm(iden = "u")]
|
||||
Unrated,
|
||||
#[sea_orm(iden = "s")]
|
||||
Safe,
|
||||
#[sea_orm(iden = "q")]
|
||||
Questionable,
|
||||
#[sea_orm(iden = "e")]
|
||||
Explicit,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum SameyPostSource {
|
||||
#[sea_orm(iden = "samey_post_source")]
|
||||
Table,
|
||||
Id,
|
||||
Url,
|
||||
PostId,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum SameyTag {
|
||||
#[sea_orm(iden = "samey_tag")]
|
||||
Table,
|
||||
Id,
|
||||
Name,
|
||||
NormalizedName,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum SameyTagPost {
|
||||
#[sea_orm(iden = "samey_tag_post")]
|
||||
Table,
|
||||
Id,
|
||||
TagId,
|
||||
PostId,
|
||||
}
|
||||
6
migration/src/main.rs
Normal file
6
migration/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
cli::run_cli(migration::Migrator).await;
|
||||
}
|
||||
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"))
|
||||
}
|
||||
54
templates/edit_post_details.html
Normal file
54
templates/edit_post_details.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<form hx-put="/post_details/{{ post.id }}" hx-target="this" hx-swap="outerHTML">
|
||||
<div>
|
||||
<label>Tags</label>
|
||||
<input
|
||||
class="tags"
|
||||
type="text"
|
||||
id="search-tags"
|
||||
name="tags"
|
||||
hx-post="/search_tags"
|
||||
hx-trigger="input changed delay:400ms"
|
||||
hx-target="next .tags-autocomplete"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
|
||||
value="{{ tags }}"
|
||||
autofocus
|
||||
/>
|
||||
<ul class="tags-autocomplete" id="search-autocomplete"></ul>
|
||||
</div>
|
||||
<div>
|
||||
<label>Title</label>
|
||||
<input name="title" type="text" maxlength="100" value="{% if let Some(title) = post.title %}{{ title }}{% endif %}" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Description</label>
|
||||
<textarea name="description">{% if let Some(description) = post.description %}{{ description }}{% endif %}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label>Is public?</label>
|
||||
<input name="is_public" type="checkbox" {% if post.is_public %}checked{% endif %} value="true" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Rating</label>
|
||||
<select name="rating">
|
||||
<option value="u" {% if post.rating == "u" %}selected{% endif %}>Unrated</option>
|
||||
<option value="s" {% if post.rating == "s" %}selected{% endif %}>Safe</option>
|
||||
<option value="q" {% if post.rating == "q" %}selected{% endif %}>Questionable</option>
|
||||
<option value="e" {% if post.rating == "e" %}selected{% endif %}>Explicit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Source(s)</label>
|
||||
<ul id="sources">
|
||||
{% for source in sources %}
|
||||
{% include "post_source.html" %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<button hx-post="/post_source" hx-target="#sources" hx-swap="beforeend">+</button>
|
||||
</div>
|
||||
<div>
|
||||
<button>Submit</button>
|
||||
<button hx-get="/post_details/{{ post.id }}">Cancel</button>
|
||||
<button hx-confirm="Are you sure that you want to delete this post? This can't be undone!" hx-delete="/post/{{ post.id }}" hx-target="body" hx-replace-url="/posts/1">Delete post</button>
|
||||
</div>
|
||||
</div>
|
||||
7
templates/get_full_media.html
Normal file
7
templates/get_full_media.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<img
|
||||
hx-get="/media/{{ post.id }}"
|
||||
hx-swap="outerHTML"
|
||||
id="media"
|
||||
src="/files/{{ post.media }}"
|
||||
style="width: {{ post.width }}px; height: {{ post.height }}px; aspect-ratio: {{ post.width }} / {{ post.height }}; cursor: zoom-out"
|
||||
/>
|
||||
7
templates/get_media.html
Normal file
7
templates/get_media.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<img
|
||||
hx-get="/media/{{ post.id }}/full"
|
||||
hx-swap="outerHTML"
|
||||
id="media"
|
||||
src="/files/{{ post.media }}"
|
||||
style="width: 100%; height: 100%; max-width: {{ post.width }}px; max-height: {{ post.height }}px; aspect-ratio: {{ post.width }} / {{ post.height }}; cursor: zoom-in"
|
||||
/>
|
||||
56
templates/index.html
Normal file
56
templates/index.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<title>Samey</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article>
|
||||
<h2>Search</h2>
|
||||
<form method="get" action="/posts/1">
|
||||
<input
|
||||
class="tags"
|
||||
type="text"
|
||||
id="search-tags"
|
||||
name="tags"
|
||||
hx-post="/search_tags"
|
||||
hx-trigger="input changed delay:400ms"
|
||||
hx-target="next .tags-autocomplete"
|
||||
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
|
||||
value=""
|
||||
autofocus
|
||||
/>
|
||||
<ul class="tags-autocomplete" id="search-autocomplete"></ul>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Upload media</h2>
|
||||
<form method="post" action="/upload" enctype="multipart/form-data">
|
||||
<input
|
||||
class="tags"
|
||||
type="text"
|
||||
id="upload-tags"
|
||||
name="tags"
|
||||
hx-post="/search_tags"
|
||||
hx-trigger="input changed delay:400ms"
|
||||
hx-target="next .tags-autocomplete"
|
||||
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
|
||||
value=""
|
||||
/>
|
||||
<ul class="tags-autocomplete" id="upload-autocomplete"></ul>
|
||||
<input
|
||||
type="file"
|
||||
id="media-file"
|
||||
name="media-file"
|
||||
accept=".jpg, .jpeg, .png, .webp, .gif"
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
48
templates/post_details.html
Normal file
48
templates/post_details.html
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<div id="post-details" hx-target="this" hx-swap="outerHTML">
|
||||
<div>
|
||||
<label>Title</label>
|
||||
{% if let Some(title) = post.title %}{{ title }}{% else %}<em>None</em>{%
|
||||
endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label>Description</label>
|
||||
{% if let Some(description) = post.description %}{{ description }}{% else
|
||||
%}<em>None</em>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label>Is public?</label>
|
||||
{% if post.is_public %}Yes{% else %}No{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label>Rating</label>
|
||||
{% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %} Safe
|
||||
{% when "q" %} Questionable {% when "e" %} Explicit {% else %} Unknown {%
|
||||
endmatch %}
|
||||
</div>
|
||||
<div>
|
||||
<label>Source(s)</label>
|
||||
{% if sources.is_empty() %}
|
||||
<em>None</em>{% else %}
|
||||
<ul>
|
||||
{% for source in sources %}
|
||||
<li id="source-{{ source.id }}">
|
||||
<a href="{{ source.url }}">{{ source.url }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label>Width</label>
|
||||
{{ post.width }}px
|
||||
</div>
|
||||
<div>
|
||||
<label>Height</label>
|
||||
{{ post.height }}px
|
||||
</div>
|
||||
<div>
|
||||
<label>Upload date</label>
|
||||
{{ post.uploaded_at }}
|
||||
</div>
|
||||
<button hx-get="/post_details/{{ post.id }}/edit">Edit</button>
|
||||
</div>
|
||||
11
templates/post_source.html
Normal file
11
templates/post_source.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<li>
|
||||
<input
|
||||
name="source"
|
||||
type="url"
|
||||
maxlength="200"
|
||||
value="{% if let Some(url) = source.url %}{{ url }}{% endif %}"
|
||||
/>
|
||||
<button hx-delete="/remove" hx-target="closest li" hx-swap="outerHTML">
|
||||
-
|
||||
</button>
|
||||
</li>
|
||||
53
templates/posts.html
Normal file
53
templates/posts.html
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<title>Posts - Samey</title>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
<h2>Search</h2>
|
||||
<form method="get" action="/posts/1">
|
||||
<input
|
||||
class="tags"
|
||||
type="text"
|
||||
id="search-tags"
|
||||
name="tags"
|
||||
hx-post="/search_tags"
|
||||
hx-trigger="input changed delay:400ms"
|
||||
hx-target="next .tags-autocomplete"
|
||||
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
|
||||
value="{% if let Some(tags_text) = tags_text %}{{ tags_text }}{% endif %}"
|
||||
/>
|
||||
<ul class="tags-autocomplete" id="search-autocomplete"></ul>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Tags</h2>
|
||||
<ul>
|
||||
{% if let Some(tags) = tags %} {% for tag in tags %}
|
||||
<li><a href="/posts/1?tags={{ tag }}">{{ tag }}</a></li>
|
||||
{% endfor %} {% endif %}
|
||||
</ul>
|
||||
</article>
|
||||
<main>
|
||||
<h1>Viewing posts</h1>
|
||||
<ul>
|
||||
{% for post in posts %}
|
||||
<li>
|
||||
<a
|
||||
href="{% if let Some(tags_text) = tags_text %}/view/{{ post.id }}?tags={{ tags_text.replace(' ', "+") }}{% else %}/view/{{ post.id }}{% endif %}"
|
||||
title="{{ post.tags }}"
|
||||
>
|
||||
<img src="/files/{{ post.thumbnail }}" />
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div>Page {{ page }} of {{ page_count }}</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
11
templates/search_tags.html
Normal file
11
templates/search_tags.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{% for tag in tags %}
|
||||
<li>
|
||||
<button
|
||||
hx-post="/select_tag/{{ tag.name }}"
|
||||
hx-target="previous .tags"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
12
templates/select_tag.html
Normal file
12
templates/select_tag.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<input
|
||||
class="tags"
|
||||
type="text"
|
||||
name="tags"
|
||||
hx-post="/search_tags"
|
||||
hx-trigger="input changed delay:400ms"
|
||||
hx-target="next .tags-autocomplete"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
|
||||
value="{{ tags }}"
|
||||
/>
|
||||
<ul class="tags-autocomplete" hx-swap-oob="outerHTML:.tags-autocomplete"></ul>
|
||||
8
templates/submit_post_details.html
Normal file
8
templates/submit_post_details.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{% include "post_details.html" %}
|
||||
<ul id="tags-list" hx-swap-oob="outerHTML">
|
||||
{% for tag in tags %}
|
||||
<li>
|
||||
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
48
templates/view_post.html
Normal file
48
templates/view_post.html
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<title>Post #{{ post.id }} - Samey</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="{% if let Some(title) = post.title %}{{ title }}{% endif %}"
|
||||
/>
|
||||
<meta property="og:url" content="/view/{{ post.id }}" />
|
||||
<meta property="og:image" content="/files/{{ post.media }}" />
|
||||
<meta property="og:image:width" content="{{ post.width }}" />
|
||||
<meta property="og:image:height" content="{{ post.height }}" />
|
||||
<!-- <meta property="og:image:alt" content="TO-DO" /> -->
|
||||
<meta
|
||||
property="og:description"
|
||||
content="{% if let Some(description) = post.description %}{{ description }}{% endif %}"
|
||||
/>
|
||||
<meta
|
||||
property="twitter:title"
|
||||
content="{% if let Some(title) = post.title %}{{ title }}{% endif %}"
|
||||
/>
|
||||
<meta property="twitter:image:src" content="/files/{{ post.media }}" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>View post #{{ post.id }}</h1>
|
||||
<div>{% include "get_media.html" %}</div>
|
||||
</main>
|
||||
<article>
|
||||
<h2>Details</h2>
|
||||
{% include "post_details.html" %}
|
||||
</article>
|
||||
<article>
|
||||
<h2>Tags</h2>
|
||||
<ul id="tags-list">
|
||||
{% for tag in tags %}
|
||||
<li>
|
||||
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue