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

1
.env Normal file
View file

@ -0,0 +1 @@
DATABASE_URL="sqlite:db.sqlite3?mode=rwc"

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
/db.sqlite3*
/files

4570
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

34
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)]
}
}

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

View 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>

View 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
View 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
View 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>

View 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>

View 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
View 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>

View 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
View 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>

View 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
View 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>